<?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: Jahanzaib Ramzan</title>
    <description>The latest articles on DEV Community by Jahanzaib Ramzan (@jahanzaibramzan).</description>
    <link>https://dev.to/jahanzaibramzan</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%2F4109630%2F6c0d7768-3e5b-4c60-9e7f-8b2966d00aad.PNG</url>
      <title>DEV Community: Jahanzaib Ramzan</title>
      <link>https://dev.to/jahanzaibramzan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jahanzaibramzan"/>
    <language>en</language>
    <item>
      <title>How to Launch Your App Successfully: A Release Checklist for React Native</title>
      <dc:creator>Jahanzaib Ramzan</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:59:51 +0000</pubDate>
      <link>https://dev.to/jahanzaibramzan/how-to-launch-your-app-successfully-a-release-checklist-for-react-native-jpg</link>
      <guid>https://dev.to/jahanzaibramzan/how-to-launch-your-app-successfully-a-release-checklist-for-react-native-jpg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jahanzaibramzan.com/blog/launching-your-app-successfully" rel="noopener noreferrer"&gt;jahanzaibramzan.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most app launches that go badly don't fail because the product is bad. They fail on the boring stuff: a rejected build two days before the announcement, a crash on one Android manufacturer that nobody tested, a login flow that breaks the moment a real user opens the app from a push notification. After shipping releases to both stores for years — from solo side projects to an app with hundreds of thousands of users — this is the checklist I run before anything goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start the store work six weeks out, not six days
&lt;/h2&gt;

&lt;p&gt;Apple and Google both have review processes, both have paperwork, and both can say no. The earlier you touch them, the less they can hurt you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App Store Connect and Play Console accounts.&lt;/strong&gt; Set these up as soon as you have a name. Apple's developer program enrolment for a company can take a couple of weeks if D-U-N-S verification is involved. Google's account verification has its own delays, and new developer accounts now need a closed test with real testers before they can publish to production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle ID and package name.&lt;/strong&gt; Decide them once. They're permanent, and they show up in deep links, Firebase configs, and every store URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy policy and data disclosures.&lt;/strong&gt; Both stores require a hosted privacy policy URL and a data-safety / privacy-nutrition form describing what you collect. If you use Firebase Analytics, Crashlytics, or any SDK that touches the ad ID, say so. Mismatches between the form and what the app actually does are a common rejection reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Age rating, content, and permissions.&lt;/strong&gt; Fill the questionnaires honestly, and make sure every permission the app requests has a visible reason in the UI. iOS reviewers reject apps that ask for camera or location access with a vague purpose string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the build reproducible
&lt;/h2&gt;

&lt;p&gt;If building a release involves someone's laptop and a series of remembered steps, it will break on the day you need it. Automate it.&lt;/p&gt;

&lt;p&gt;I use Fastlane for both platforms, with lanes that bump the build number, build, sign, and upload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastlane/Fastfile&lt;/span&gt;
&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:ios&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:release&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;increment_build_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;xcodeproj: &lt;/span&gt;&lt;span class="s2"&gt;"ios/MyApp.xcodeproj"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"appstore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;readonly: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;build_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;workspace: &lt;/span&gt;&lt;span class="s2"&gt;"ios/MyApp.xcworkspace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scheme: &lt;/span&gt;&lt;span class="s2"&gt;"MyApp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;upload_to_testflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;skip_waiting_for_build_processing: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:android&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:release&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;gradle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;task: &lt;/span&gt;&lt;span class="s2"&gt;"bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;build_type: &lt;/span&gt;&lt;span class="s2"&gt;"Release"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;project_dir: &lt;/span&gt;&lt;span class="s2"&gt;"android/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;upload_to_play_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;track: &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;aab: &lt;/span&gt;&lt;span class="n"&gt;lane_context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;SharedValues&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;GRADLE_AAB_OUTPUT_PATH&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signing is the part that bites. Use Fastlane &lt;code&gt;match&lt;/code&gt; (or a similar shared store) for iOS certificates so the whole team can build. On Android, enrol in Play App Signing and keep the upload key somewhere you can't lose it — a lost upload key is a support ticket, a lost legacy signing key is a new app listing.&lt;/p&gt;

&lt;p&gt;If you're on Expo, EAS Build and EAS Submit cover the same ground with less setup. Either way, the release should be one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test on real devices, including bad ones
&lt;/h2&gt;

&lt;p&gt;Simulators lie. They have unlimited memory, perfect networks, and no manufacturer quirks. Before a launch, the build needs time on physical hardware.&lt;/p&gt;

&lt;p&gt;The minimum I want in hand: one recent iPhone, one older iPhone still on a supported iOS, one flagship Android, and one cheap Android with 3–4 GB of RAM — ideally a Samsung or Xiaomi, because their Android skins are where the surprising bugs live (aggressive background killing, notification channels, and permission dialogs that behave differently).&lt;/p&gt;

&lt;p&gt;Test the flows that only exist in production: opening the app from a push notification when it's cold, from a deep link when it's backgrounded, after the OS kills it and restores state, and on a slow network with airplane mode toggled mid-request. Log in with a brand-new account, not your dev account with 200 test records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship to testers before you ship to everyone
&lt;/h2&gt;

&lt;p&gt;Both stores give you free staged distribution. Use all of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TestFlight&lt;/strong&gt; for iOS: internal testers immediately, external testers after a lighter review. Get at least a week of real usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play internal → closed → open testing&lt;/strong&gt;: same idea. Open testing also lets you gather pre-launch reviews that don't count against your production rating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ask testers for specific things — "install, log in, complete one core task, kill the app, reopen" — not "let me know what you think." You'll get bug reports instead of opinions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Have monitoring on before the first install
&lt;/h2&gt;

&lt;p&gt;If you don't have crash reporting when you launch, you'll find out about crashes from one-star reviews. Crashlytics is free and takes an hour to add to a React Native app; Sentry is a good alternative and handles JS errors more gracefully.&lt;/p&gt;

&lt;p&gt;Set up, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crash reporting with source maps uploaded so JS stack traces are readable.&lt;/li&gt;
&lt;li&gt;Analytics for the handful of events that define success: first open, sign-up completed, core action completed, day-2 return.&lt;/li&gt;
&lt;li&gt;Alerting on crash-free rate dropping below a threshold, so you hear about a bad release in hours, not days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check that you can actually see events from a TestFlight build before launch day. Dashboards that show nothing are a common surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roll out gradually
&lt;/h2&gt;

&lt;p&gt;You don't have to release to 100% of users at once, and you shouldn't.&lt;/p&gt;

&lt;p&gt;On Google Play, staged rollout lets you release to 5%, then 20%, then 50%, then everyone, halting if crash rates spike. On the App Store, phased release does the same over seven days for automatic updates. Turn both on for every release, not just the first.&lt;/p&gt;

&lt;p&gt;Pair that with a remote kill switch: a Remote Config flag or a tiny Firestore document the app reads at startup that can disable a feature or show a "please update" screen. When something goes wrong at 3 a.m., turning off one feature is much better than waiting a day for a review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the store listing for humans, not for you
&lt;/h2&gt;

&lt;p&gt;The listing gets you the install; the app has to earn the second open. A few things that consistently help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first screenshot is the one that matters. Show the core outcome, with a short caption, not a login screen.&lt;/li&gt;
&lt;li&gt;The first two lines of the description are visible before "more". Say what the app does and who it's for in plain language.&lt;/li&gt;
&lt;li&gt;Use the subtitle (iOS) and short description (Android) for the main keyword phrase you want to be found for.&lt;/li&gt;
&lt;li&gt;Localise the listing for the two or three languages where you expect most users. Machine translation of the listing is acceptable; of the app itself, less so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Plan the first week, not just the first day
&lt;/h2&gt;

&lt;p&gt;Launch day is the start of the work. Block out the week for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone watching Crashlytics and reviews daily, with the ability to ship a hotfix within 24 hours.&lt;/li&gt;
&lt;li&gt;Reply to reviews, especially the negative ones. Public replies affect conversion.&lt;/li&gt;
&lt;li&gt;Look at the funnel from first open to core action. If 60% drop off at sign-up, that's the next release.&lt;/li&gt;
&lt;li&gt;Hold back at least one meaningful improvement so version 1.1 has something to say two weeks later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store accounts, bundle IDs, privacy policy, data forms, age ratings: done six weeks out.&lt;/li&gt;
&lt;li&gt;Release build is one command; signing is shared and backed up.&lt;/li&gt;
&lt;li&gt;Tested on real devices, including a cheap Android; production-only flows covered.&lt;/li&gt;
&lt;li&gt;TestFlight / Play testing tracks used for at least a week.&lt;/li&gt;
&lt;li&gt;Crash reporting, analytics, and alerts verified working before launch.&lt;/li&gt;
&lt;li&gt;Staged rollout on, kill switch in place.&lt;/li&gt;
&lt;li&gt;Listing written for users, first screenshot shows the outcome.&lt;/li&gt;
&lt;li&gt;First-week plan: monitoring, reviews, hotfix capacity, a 1.1 in the pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous, and all of it is the difference between a launch that builds momentum and one that spends its first week apologising. If you'd like help getting a React Native app across the line — release engineering, store submission, or a pre-launch audit — see &lt;a href="https://jahanzaibramzan.com/services" rel="noopener noreferrer"&gt;Services&lt;/a&gt; or &lt;a href="https://jahanzaibramzan.com/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>devops</category>
      <category>android</category>
    </item>
    <item>
      <title>Firestore Data Modeling for Offline-First React Native Apps</title>
      <dc:creator>Jahanzaib Ramzan</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:59:25 +0000</pubDate>
      <link>https://dev.to/jahanzaibramzan/firestore-data-modeling-for-offline-first-react-native-apps-1k9i</link>
      <guid>https://dev.to/jahanzaibramzan/firestore-data-modeling-for-offline-first-react-native-apps-1k9i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jahanzaibramzan.com/blog/firestore-data-modeling-offline-first-react-native" rel="noopener noreferrer"&gt;jahanzaibramzan.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Firestore ships with offline persistence turned on for iOS and Android, so a lot of people assume "offline-first" is a checkbox they've already ticked. It isn't. The SDK will cache documents and queue writes, but whether the app actually &lt;em&gt;feels&lt;/em&gt; instant on a train or in a basement depends almost entirely on how the data is shaped. Get the model right and Firestore does the hard part for you. Get it wrong and you'll spend months adding loading spinners and retry logic to paper over it.&lt;/p&gt;

&lt;p&gt;This is how I model Firestore for React Native apps where users expect things to work whether or not they have signal — the same approach that keeps a Quran-learning app usable for hundreds of thousands of learners, many of them on patchy mobile networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cache can and can't do
&lt;/h2&gt;

&lt;p&gt;Two facts drive every decision below.&lt;/p&gt;

&lt;p&gt;First, Firestore caches &lt;em&gt;documents you've read&lt;/em&gt; and &lt;em&gt;queries you've run&lt;/em&gt;. If a screen needs a document the user has never opened, it won't be there offline. So the model has to make sure the important data gets pulled in early, in as few reads as possible.&lt;/p&gt;

&lt;p&gt;Second, offline writes are queued and replayed in order when the device reconnects. Each write is applied as a &lt;em&gt;last write wins&lt;/em&gt; patch to the fields it touches. Two devices editing the same field will conflict silently; two devices editing different fields of the same document will merge fine. That single rule tells you how to split documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split by who writes, not by what it "is"
&lt;/h2&gt;

&lt;p&gt;The relational habit is to group data by entity: one &lt;code&gt;users/{uid}&lt;/code&gt; document with everything about the user. For offline, group it by &lt;em&gt;who writes it and how often&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A typical learning or social app ends up with something 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;users/{uid}                     ← profile: display name, avatar, settings (user writes, rarely)
users/{uid}/progress/current    ← streak, level, last active (user writes, constantly)
users/{uid}/reviews/{itemId}    ← spaced-repetition state per item (user writes, offline-heavy)
users/{uid}/stats/summary       ← totals, rank (Cloud Function writes, never the client)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters offline: the &lt;code&gt;progress&lt;/code&gt; document is small and written by exactly one device most of the time, so queued writes merge cleanly. The &lt;code&gt;stats/summary&lt;/code&gt; document is written only by the server, so the client never queues a write to it and can never create a conflict. And the profile isn't cluttered with high-churn fields, so it stays cached and stable.&lt;/p&gt;

&lt;p&gt;Contrast that with one big &lt;code&gt;users/{uid}&lt;/code&gt; document holding all of it. A queued offline update to &lt;code&gt;streak&lt;/code&gt; and a Cloud Function updating &lt;code&gt;rank&lt;/code&gt; at the same time will step on each other, and every small change re-sends and re-caches the whole document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the first screen one read
&lt;/h2&gt;

&lt;p&gt;Before the user has done anything, the app needs enough to render the home screen. I aim for one document read, occasionally two.&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;// On app start — one listener that keeps the home screen alive offline&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;onSnapshot&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;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&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;current&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;includeMetadataChanges&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&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;setProgress&lt;/span&gt;&lt;span class="p"&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="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasPendingWrites&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fromCache&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;Everything the home screen shows — today's due count, streak, next lesson pointer — lives in that document. It's denormalised on purpose: the Cloud Function that processes a completed lesson also updates &lt;code&gt;progress/current&lt;/code&gt; with the next due count, so the client never has to run a query to figure it out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hasPendingWrites&lt;/code&gt; and &lt;code&gt;fromCache&lt;/code&gt; are worth surfacing in the UI. A small "Saved on this device — will sync when online" hint beats a spinner that never resolves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep per-item state as small documents, not arrays
&lt;/h2&gt;

&lt;p&gt;Spaced repetition, watchlists, bookmarks, completed lessons — anything where the user accumulates per-item state — is tempting to store as an array on the user document:&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;// Don't do this&lt;/span&gt;
&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reviewed&lt;/span&gt;&lt;span class="p"&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;w_12&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;w_57&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;w_98&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&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;Arrays grow without bound, every change rewrites the whole thing, and two offline devices appending different items will overwrite each other. Use a subcollection with one document per item:&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;// users/{uid}/reviews/{wordId}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// days&lt;/span&gt;
  &lt;span class="nx"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;due&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="nx"&gt;reps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;updatedAt&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "what's due today" query is then cheap and cacheable:&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;dueQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reviews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;due&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;&amp;lt;=&lt;/span&gt;&lt;span class="dl"&gt;"&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;fromDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;endOfToday&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="s2"&gt;due&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&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;Because it's the same query every day, Firestore keeps it warm in the cache. Offline, the user still sees today's reviews; the updates they make queue up and replay when they reconnect, one small patch per document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate shared content from user state
&lt;/h2&gt;

&lt;p&gt;Vocabulary lists, lesson content, movie catalogs, level definitions — anything the same for every user — should never be mixed into user documents. Two reasons: it bloats every user's cache with copies of the same content, and it makes content updates a fan-out write to every user.&lt;/p&gt;

&lt;p&gt;Put it in its own collection, version it, and cache it deliberately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content/{version}/lessons/{lessonId}
content/meta { currentVersion: 42 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On startup, read &lt;code&gt;content/meta&lt;/code&gt; (one read), compare to what's on disk, and only pull lessons the user hasn't cached yet. For large static content, a Firestore data bundle or a JSON file in Cloud Storage with a version stamp is cheaper still.&lt;/p&gt;

&lt;p&gt;This also answers "what happens when the user is offline and the content changes?" — nothing, until they're back online and the version bumps. Old content keeps working because it's immutable by version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the server own anything competitive
&lt;/h2&gt;

&lt;p&gt;Leaderboards, follower counts, rankings, "trending" — anything multiple users influence at once — must be computed server-side and read by the client. Never have the client increment a shared counter directly; offline devices replaying queued increments hours later will produce numbers that don't add up, and you'll have no way to reconcile them.&lt;/p&gt;

&lt;p&gt;The client writes an &lt;em&gt;event&lt;/em&gt; (a completed challenge, a like), a Cloud Function aggregates, and the client reads the result:&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;// Client: append-only, safe to queue offline&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addDoc&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;events&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge_completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;// resolved on the server, not the device clock&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Cloud Function: the only writer for stats/summary and leaderboards&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onDocumentCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users/{uid}/events/{id}&lt;/span&gt;&lt;span class="dl"&gt;"&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;e&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&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;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;points&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;await&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;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/stats/summary`&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;totalPoints&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="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;updatedAt&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;serverTimestamp&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="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;Note &lt;code&gt;serverTimestamp()&lt;/code&gt;. Device clocks are unreliable, and an offline device replaying events with local timestamps will put them in the wrong order. Let the server stamp them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeds: cache the first page, paginate the rest
&lt;/h2&gt;

&lt;p&gt;For social or content feeds, offline users should see &lt;em&gt;something&lt;/em&gt; — the last page they loaded. That happens for free if the feed query is stable and paginated by cursor:&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;feedQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visibility&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;==&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;public&lt;/span&gt;&lt;span class="dl"&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="s2"&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="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&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;Denormalise what the card needs (author name, avatar, counts) into the post document so rendering the cached page doesn't trigger secondary reads that fail offline. Load subsequent pages with &lt;code&gt;startAfter(lastDoc)&lt;/code&gt; only when online.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security rules that don't break offline
&lt;/h2&gt;

&lt;p&gt;Rules run on the server when writes are replayed, not when they're queued. So a write the device accepted offline can be rejected later, and the SDK will surface it as an error on the promise you've probably long stopped awaiting.&lt;/p&gt;

&lt;p&gt;Two mitigations. Keep rules simple and ownership-based (&lt;code&gt;request.auth.uid == uid&lt;/code&gt; on &lt;code&gt;users/{uid}/**&lt;/code&gt;), so the client can't easily queue something that will be refused. And attach a listener for write failures so rejected writes at least get logged:&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="nf"&gt;setDoc&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="nx"&gt;data&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;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Queued write rejected on sync&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;path&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="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;h2&gt;
  
  
  Test offline like you mean it
&lt;/h2&gt;

&lt;p&gt;The simulator's network toggle is not enough. On a real device: open the app online, kill it, enable airplane mode, reopen it, use it for five minutes, then reconnect and watch what syncs. Check Firestore in the console for documents that look wrong. Then do it with two devices on the same account.&lt;/p&gt;

&lt;p&gt;The bugs you'll find are always the same: a screen that needed a document nobody had read yet, a shared counter the client was incrementing, and an array that got overwritten. Every one of them is a modeling problem, not a Firestore problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern in one paragraph
&lt;/h2&gt;

&lt;p&gt;Small documents split by writer and churn. One read for the first screen, denormalised on write by a Cloud Function. Per-item state as subcollections, not arrays. Shared content versioned and separate from user state. Anything competitive computed server-side from client-written events. Stable, cursor-paginated queries for feeds. Ownership-based rules. Test on real hardware with airplane mode.&lt;/p&gt;

&lt;p&gt;If you're building or rescuing a React Native app on Firebase and offline behaviour is where it hurts, that's a large part of what I do on the &lt;a href="https://jahanzaibramzan.com/work/kalaam-quran-learning-app" rel="noopener noreferrer"&gt;Kalaam case study&lt;/a&gt; and in &lt;a href="https://jahanzaibramzan.com/services#firebase-architecture" rel="noopener noreferrer"&gt;Firebase architecture work&lt;/a&gt; — or &lt;a href="https://jahanzaibramzan.com/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; to talk through your data model.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>reactnative</category>
      <category>database</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Scaling Firebase for Growing Mobile Apps</title>
      <dc:creator>Jahanzaib Ramzan</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:17:47 +0000</pubDate>
      <link>https://dev.to/jahanzaibramzan/scaling-firebase-for-growing-mobile-apps-1g5f</link>
      <guid>https://dev.to/jahanzaibramzan/scaling-firebase-for-growing-mobile-apps-1g5f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jahanzaibramzan.com/blog/scaling-firebase-for-mobile-apps" rel="noopener noreferrer"&gt;jahanzaibramzan.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Firebase is the fastest way I know to get a mobile app from idea to store. It's also very easy to build something that works beautifully at 1,000 users and falls over — or gets expensive — at 100,000. The difference is almost never Firebase itself. It's a few data-modeling and Cloud Functions decisions that are cheap to make early and painful to change later.&lt;/p&gt;

&lt;p&gt;This is what I've learned keeping a Firebase-backed React Native app healthy as it grew from around 10,000 to more than 300,000 users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model for reads, not for tidiness
&lt;/h2&gt;

&lt;p&gt;Firestore bills per document read, and every read is a network round trip on a phone. So the question for every screen is: how many documents does this screen need to open? If the answer is "one per item in the list," you have a problem that will get worse in proportion to your success.&lt;/p&gt;

&lt;p&gt;The instinct from relational databases is to normalise: a &lt;code&gt;users&lt;/code&gt; collection, a &lt;code&gt;posts&lt;/code&gt; collection, a &lt;code&gt;comments&lt;/code&gt; collection, and join them on the client. In Firestore, that turns a feed of 20 posts into 20 post reads plus 20 author reads plus 20 comment-count queries.&lt;/p&gt;

&lt;p&gt;Instead, denormalise what the screen shows. A post document should carry the author's display name and avatar URL, the like count, and the comment count. Update those copies when the source changes — usually from a Cloud Function, so the client never has to.&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;// posts/{postId}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;authorId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;u_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ayesha&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatarUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// copied, not joined&lt;/span&gt;
  &lt;span class="nx"&gt;likeCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;commentCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;createdAt&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One read per list item, and the client stays simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep counters out of the client
&lt;/h2&gt;

&lt;p&gt;Counting is the classic Firestore trap. &lt;code&gt;collection("posts").where("authorId", "==", uid).get().size&lt;/code&gt; reads every document just to count them. At scale that's slow, expensive, and racy.&lt;/p&gt;

&lt;p&gt;For anything users see constantly — likes, followers, streaks, leaderboard points — maintain a stored counter and update it server-side:&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;// Cloud Function: keep commentCount in sync&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onCommentCreated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onDocumentCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts/{postId}/comments/{commentId}&lt;/span&gt;&lt;span class="dl"&gt;"&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;event&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;postRef&lt;/span&gt; &lt;span class="o"&gt;=&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;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postId&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;await&lt;/span&gt; &lt;span class="nx"&gt;postRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;commentCount&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="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;For counters that many users hit at once (a viral post, a global leaderboard), a single document caps out at roughly one write per second. Use the distributed counter pattern — a handful of shard documents summed on read — or aggregate periodically with a scheduled function. For "count all documents" cases where the number just needs to be shown, &lt;code&gt;count()&lt;/code&gt; aggregation queries are much cheaper than reading every document, though they still aren't free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Precompute the expensive views
&lt;/h2&gt;

&lt;p&gt;A weekly leaderboard that's recalculated every time someone opens the screen is a scaling problem waiting to happen. The pattern that works: compute it once, on a schedule, and write the result to a small document the app reads.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buildWeeklyLeaderboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;every 15 minutes&lt;/span&gt;&lt;span class="dl"&gt;"&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="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;top&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;db&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="s2"&gt;users&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="s2"&gt;weeklyPoints&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;desc&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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;await&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;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leaderboards/weekly&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;updatedAt&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;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;top&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="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="na"&gt;uid&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="na"&gt;name&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;displayName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;avatarUrl&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avatarUrl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;points&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;weeklyPoints&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 every user opening the leaderboard costs one read, regardless of how many users you have. The same idea applies to "recommended for you", "trending", daily challenge content, and anything else that's shared across many users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design for offline from day one
&lt;/h2&gt;

&lt;p&gt;Firestore's offline persistence is enabled by default on iOS and Android, and it's one of the best reasons to use Firebase for mobile. But it only helps if your data model plays along.&lt;/p&gt;

&lt;p&gt;Keep the documents a user interacts with most — their own profile, progress, settings — small and self-contained, so they cache well and can be written offline without conflicts. Avoid patterns where a single user action must update several documents atomically from the client; move that into a Cloud Function triggered by one write.&lt;/p&gt;

&lt;p&gt;In the React Native app, read from the cache first and let the server update arrive:&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;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSnapshot&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;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&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;current&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;includeMetadataChanges&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&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;setProgress&lt;/span&gt;&lt;span class="p"&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="nf"&gt;setIsFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fromCache&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;Users get an instant screen; you get fewer "the app is slow" reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write security rules that scale with you
&lt;/h2&gt;

&lt;p&gt;Rules are evaluated on every request, and rules that read other documents (&lt;code&gt;get()&lt;/code&gt; / &lt;code&gt;exists()&lt;/code&gt;) count as reads. A rule that checks a user's role by fetching their profile document on every read effectively doubles your read bill.&lt;/p&gt;

&lt;p&gt;Two habits keep this in check. First, put authorisation data on the auth token as custom claims, set from a Cloud Function, so rules can check &lt;code&gt;request.auth.token.role&lt;/code&gt; with no extra read:&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;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="o"&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;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, structure paths so ownership is obvious from the path itself:&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;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/{document=**} &lt;/span&gt;&lt;span class="err"&gt;{
&lt;/span&gt;  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&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;Test rules with the emulator suite before deploying. A rules bug at scale is either a data leak or a wave of permission-denied crashes, and both show up in reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Cloud Functions fast and cheap
&lt;/h2&gt;

&lt;p&gt;Functions scale automatically, but each cold start adds latency and every instance costs money. The things that matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin a region close to your users and your Firestore location. Cross-region calls add hundreds of milliseconds.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;minInstances: 1&lt;/code&gt; on the handful of functions that sit in the user's critical path (login, first-screen data) so they don't cold start.&lt;/li&gt;
&lt;li&gt;Keep functions small and single-purpose. A 40 MB bundle that imports the entire Admin SDK plus half of npm starts slower than one that imports what it uses.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;onCall&lt;/code&gt; functions for client-to-server RPC rather than raw HTTPS; you get auth context and typed errors for free.&lt;/li&gt;
&lt;li&gt;Add idempotency to triggers. Firestore triggers can fire more than once; an &lt;code&gt;increment(1)&lt;/code&gt; that runs twice is a bug you'll only notice in the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch the Functions dashboard for execution time and error rate per function; the slow one is usually obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control cost before it controls you
&lt;/h2&gt;

&lt;p&gt;A Firebase bill that surprises you is a data-model bill. Once denormalisation, stored counters, and precomputed views are in place, the remaining levers are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pagination everywhere.&lt;/strong&gt; Never load a collection without &lt;code&gt;limit()&lt;/code&gt;. Use &lt;code&gt;startAfter()&lt;/code&gt; with a cursor, not offset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle static content.&lt;/strong&gt; Reference data that rarely changes (vocabulary lists, level definitions, category trees) can be shipped as a Firestore data bundle or plain JSON in Cloud Storage with a version stamp, rather than read per user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage image variants.&lt;/strong&gt; Generate resized images at upload with the Resize Images extension so phones download thumbnails, not originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget alerts.&lt;/strong&gt; Set a Google Cloud budget with alerts at 50/80/100%. It costs nothing and it's the only way to catch a runaway loop before month-end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Plan the migrations you'll need
&lt;/h2&gt;

&lt;p&gt;You will change your data model. Firestore has no schema migrations, so plan for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a &lt;code&gt;schemaVersion&lt;/code&gt; field to documents you expect to evolve.&lt;/li&gt;
&lt;li&gt;Write client code that tolerates missing fields rather than crashing on old documents.&lt;/li&gt;
&lt;li&gt;For large backfills, run a Cloud Function or a script with the Admin SDK in batches of 500 writes, with a checkpoint so you can resume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app I've spent the most time on has been through several of these; none were fun, but all were manageable because the versions were explicit and the client was forgiving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Model each screen for one or two reads. Keep counters and aggregates in Cloud Functions. Precompute anything shared across users. Design documents to cache and write offline. Keep rules read-free. Paginate. Set budget alerts.&lt;/p&gt;

&lt;p&gt;Do those things while the app is small and Firebase will carry you a very long way. If your app is already past that point and the bill or the latency is climbing, that's the kind of audit I do — see &lt;a href="https://jahanzaibramzan.com/services" rel="noopener noreferrer"&gt;Services&lt;/a&gt; or &lt;a href="https://jahanzaibramzan.com/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>reactnative</category>
      <category>database</category>
      <category>mobile</category>
    </item>
    <item>
      <title>10 Tips to Improve Performance in React Native Apps</title>
      <dc:creator>Jahanzaib Ramzan</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:16:11 +0000</pubDate>
      <link>https://dev.to/jahanzaibramzan/10-tips-to-improve-performance-in-react-native-apps-2aif</link>
      <guid>https://dev.to/jahanzaibramzan/10-tips-to-improve-performance-in-react-native-apps-2aif</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jahanzaibramzan.com/blog/react-native-performance-tips" rel="noopener noreferrer"&gt;jahanzaibramzan.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most React Native performance problems I get called in to fix aren't exotic. They're the same handful of issues, repeated: lists that re-render everything on every keystroke, images that are ten times larger than the box they sit in, screens that fetch more data than they show, and a startup path that does far too much work before the first frame. This is the list I actually work through on client apps, roughly in the order I check them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Measure before you touch anything
&lt;/h2&gt;

&lt;p&gt;Performance work without measurement is guessing. Before changing code, get a baseline for the three things users feel: time to first interactive screen, frame drops during scroll and animation, and the delay between a tap and visible feedback.&lt;/p&gt;

&lt;p&gt;The built-in Performance Monitor (shake → &lt;em&gt;Perf Monitor&lt;/em&gt;) shows JS and UI frame rates. For anything deeper use Flipper with the React DevTools plugin, or the React Native DevTools profiler. On Android, &lt;code&gt;adb shell dumpsys gfxinfo &amp;lt;package&amp;gt;&lt;/code&gt; gives frame timing without any extra tooling.&lt;/p&gt;

&lt;p&gt;Write the numbers down. Then when you apply one of the fixes below, you'll know whether it helped or whether you just made the code more complicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Replace FlatList with FlashList for long lists
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FlatList&lt;/code&gt; recreates list item components as they scroll into view. On a feed with hundreds of items and images, that's the single biggest source of dropped frames I see. Shopify's &lt;code&gt;@shopify/flash-list&lt;/code&gt; recycles item views instead, and on the same data it typically cuts scroll jank dramatically.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FlashList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@shopify/flash-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FlashList&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;renderItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PostCard&lt;/span&gt; &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;estimatedItemSize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;keyExtractor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&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;The one thing it needs is &lt;code&gt;estimatedItemSize&lt;/code&gt; — an honest guess at the average row height. Get that roughly right and the rest is a drop-in replacement. If you can't migrate yet, at least set &lt;code&gt;windowSize&lt;/code&gt;, &lt;code&gt;maxToRenderPerBatch&lt;/code&gt;, and &lt;code&gt;removeClippedSubviews&lt;/code&gt; on &lt;code&gt;FlatList&lt;/code&gt; — but migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stop list items from re-rendering
&lt;/h2&gt;

&lt;p&gt;Even with FlashList, if every row re-renders when unrelated state changes, you're back to jank. Two habits fix most of it.&lt;/p&gt;

&lt;p&gt;Wrap row components in &lt;code&gt;React.memo&lt;/code&gt;, and make sure the props you pass are stable:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PostCard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onPress&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&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="p"&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="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&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="si"&gt;}&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="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&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="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;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// In the parent — stable callback, not a new arrow function per render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handlePress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Post&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="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;navigate&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The common mistake is memoizing the child and then passing it a fresh &lt;code&gt;{ style: {...} }&lt;/code&gt; object or inline function on every render, which defeats the memo entirely. If a memoized component still re-renders, the React DevTools "Highlight updates" toggle will show you which prop changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Size images for the box they fill
&lt;/h2&gt;

&lt;p&gt;A 4000×3000 photo rendered into a 120×90 thumbnail still has to be decoded at full size. Multiply that by a scrolling list and you've got memory pressure, decode stalls, and OOM crashes on low-end Android.&lt;/p&gt;

&lt;p&gt;Serve images at roughly the display size (2× for retina is fine), and cache them on disk. &lt;code&gt;expo-image&lt;/code&gt; handles caching, placeholders, and transitions well; &lt;code&gt;react-native-fast-image&lt;/code&gt; does the same on bare RN.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// a 240px variant, not the original&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;contentFit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cover"&lt;/span&gt;
  &lt;span class="na"&gt;cachePolicy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"memory-disk"&lt;/span&gt;
  &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;blurhash&lt;/span&gt;&lt;span class="si"&gt;}&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;If your backend is Firebase Storage, generate resized variants at upload time with the Resize Images extension rather than resizing on the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make sure Hermes is on, and check your bundle
&lt;/h2&gt;

&lt;p&gt;Hermes has been the default engine for a while, but I still find apps that turned it off during a migration and never turned it back on. Confirm it in &lt;code&gt;android/gradle.properties&lt;/code&gt; (&lt;code&gt;hermesEnabled=true&lt;/code&gt;) and the iOS Podfile, then check at runtime:&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;isHermes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;HermesInternal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you're there, look at what's actually in your JS bundle. &lt;code&gt;npx react-native-bundle-visualizer&lt;/code&gt; shows it as a treemap. Every time I run it on a client project there's something surprising — a full &lt;code&gt;lodash&lt;/code&gt; import for two functions, &lt;code&gt;moment&lt;/code&gt; with every locale, an icon library pulling in thousands of glyphs. Swapping to &lt;code&gt;lodash/debounce&lt;/code&gt;, &lt;code&gt;date-fns&lt;/code&gt;, and a tree-shakeable icon set can cut startup by hundreds of milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Move work off the startup path
&lt;/h2&gt;

&lt;p&gt;Cold start is where apps feel slowest, and it's usually self-inflicted. The pattern to look for: the root component fetching remote config, hydrating a persisted store, initialising analytics, checking auth, and registering push notifications — all before rendering anything.&lt;/p&gt;

&lt;p&gt;Split it into what the first screen actually needs and what can wait:&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="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="c1"&gt;// Needed to render the first screen&lt;/span&gt;
  &lt;span class="nf"&gt;restoreAuthSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Everything else after first paint&lt;/span&gt;
  &lt;span class="nx"&gt;InteractionManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runAfterInteractions&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;initAnalytics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;registerForPushNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;prefetchSecondaryData&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;Persisted state (Redux Persist, Zustand's &lt;code&gt;persist&lt;/code&gt;) should only include what you need on launch; a 2 MB cached feed in AsyncStorage is a startup cost, not a feature. Lazy-load heavy screens with &lt;code&gt;React.lazy&lt;/code&gt; so their code isn't parsed until someone navigates there.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Keep animations on the UI thread
&lt;/h2&gt;

&lt;p&gt;Any animation driven by JS state updates competes with everything else the JS thread is doing, so it stutters the moment a list scrolls or a network response arrives. Reanimated runs animations as worklets on the UI thread and stays smooth under load.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSharedValue&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&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="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&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="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withTiming&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&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;For gestures, pair it with &lt;code&gt;react-native-gesture-handler&lt;/code&gt; so the whole interaction stays off the JS thread. For the old &lt;code&gt;Animated&lt;/code&gt; API, &lt;code&gt;useNativeDriver: true&lt;/code&gt; gets you part of the way for opacity and transforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Fetch less, cache more
&lt;/h2&gt;

&lt;p&gt;Slow screens are often just waiting on the network. Three things help.&lt;/p&gt;

&lt;p&gt;First, don't fetch on every focus. TanStack Query (or SWR) gives you stale-while-revalidate for free: show cached data immediately, refresh in the background.&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&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;post&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchPost&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="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;Second, fetch only what the screen shows. A list needs titles and thumbnails, not the full body of every item.&lt;/p&gt;

&lt;p&gt;Third, if you're on Firestore: enable offline persistence (it's on by default on mobile), model data so a screen is one or two reads rather than a fan-out of N queries, and precompute counters and aggregates in Cloud Functions instead of counting on the client. Firestore bills per document read and each read is a round trip — both add up fast on a feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Watch for expensive re-renders from context and stores
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;React.Context&lt;/code&gt; holding the whole app state means every consumer re-renders whenever any field changes. The same applies to selecting the whole store from Redux or Zustand.&lt;/p&gt;

&lt;p&gt;Split contexts by how often they change (auth vs. theme vs. feed data), and select narrowly:&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="c1"&gt;// Re-renders only when `unreadCount` changes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unreadCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Re-renders on every store update — avoid&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStore&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;why-did-you-render&lt;/code&gt; is worth adding in development for a week; it's blunt but it finds these fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Test on the phone your users actually have
&lt;/h2&gt;

&lt;p&gt;Everything above will look fine on an iPhone 15 or a Pixel 8. Most of the world isn't on those. Keep one genuinely cheap Android device — something with 3–4 GB of RAM from a few years ago — and make it part of the QA pass before every release. Enable &lt;em&gt;Don't keep activities&lt;/em&gt; in developer options once in a while too; it exposes state-restoration bugs that only appear when the OS kills your app in the background.&lt;/p&gt;

&lt;p&gt;Release builds only. Debug builds run the JS through the Metro dev server with extra checks, and are not representative of what users get.&lt;/p&gt;

&lt;h2&gt;
  
  
  A workflow that sticks
&lt;/h2&gt;

&lt;p&gt;The fixes above are fairly mechanical. What keeps an app fast is doing them consistently: set a performance budget (for example, first screen under 1.5 s on the test device, no dropped frames on the main feed), check it in CI or at least before each release, and treat a regression like a bug rather than a polish task for later.&lt;/p&gt;

&lt;p&gt;I've applied this checklist on apps ranging from a Quran-learning app with 300K+ users on Firebase to solo Android projects, and the outcome is usually the same: the first two or three items fix most of the complaints, and the rest keep them from coming back.&lt;/p&gt;

&lt;p&gt;If you have a React Native app that has gotten slow and you'd like a second pair of eyes on it, I do technical audits — see &lt;a href="https://jahanzaibramzan.com/services" rel="noopener noreferrer"&gt;Services&lt;/a&gt; or &lt;a href="https://jahanzaibramzan.com/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>performance</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
