When I joined the mobile platform team, GraphQL data fetching ran through Apollo Client. It worked, but our app already used Redux for auth, customer-tenant configuration, and persistence. Apollo added a second cache, a second set of loading/error patterns, and codegen pipelines that drifted from the rest of the stack.
We decided to standardize on RTK Query — not in a big-bang rewrite, but module by module, while shipping features every week. This post describes the migration strategy that actually survived production.
Why leave Apollo?
Apollo is excellent for GraphQL-first teams. Our friction was organizational as much as technical:
- Two caches — Apollo normalized cache vs Redux state led to stale UI after customer-tenant switches and active-account changes.
- Inconsistent patterns — some modules used Apollo hooks, others used RTK from earlier experiments, and error handling differed everywhere.
- Codegen overhead — generated types lived in a monolithic file; small schema changes created noisy diffs across unrelated modules.
- Team familiarity — most mobile engineers already worked in Redux daily; RTK Query extended that model instead of competing with it.
The goal was not "GraphQL vs REST." We kept GraphQL endpoints. We changed how the client cached and invalidated data.
Migration principles
We agreed on rules before touching the first module:
- One module at a time — no parallel migrations in the same release train unless teams were fully independent.
- Feature parity first — same loading states, same error surfaces, same cache invalidation behavior as the Apollo version.
- Tags for invalidation — every RTK endpoint declares tags; customer-tenant switch, active-account switch, and pull-to-refresh use centralized invalidation helpers.
- Delete Apollo code for the migrated module in the same PR — no long-lived dual implementations for the same screen.
- Document the pattern — once tooltips and priority filters shipped on RTK, that PR became the reference for other teams.
- Teach the pattern to AI assistants — team-owned AI coding guidance tells developers when to create a query, when to reuse an existing endpoint, and how tags must be named so generated code does not misuse RTK Query.
A concrete module: Tooltips
Tooltips were a good first migration candidate: bounded scope, read-heavy, clear cache keys, few mutations.
The steps:
// 1. Define the endpoint in the module API slice
getTooltips: builder.query<TooltipResponse, TooltipArgs>({
query: (args) => ({ document: GET_TOOLTIPS, variables: args }),
providesTags: (result, error, args) => [
{ type: 'Tooltip', id: args.moduleId },
],
}),
// 2. Replace useQuery from Apollo with useGetTooltipsQuery
const { data, isLoading, isError } = useGetTooltipsQuery({ moduleId })
// 3. Wire invalidation on customer-tenant switch
dispatch(api.util.invalidateTags([{ type: 'Tooltip', id: moduleId }]))
We manually defined RTK types for Banner and Tooltip initially. That was intentional — it let us validate the pattern before investing in codegen for every module.
Priority filters: merging duplicated wrappers
Another early win was consolidating multiple PriorityFilterWrapper components that each wrapped Apollo hooks with slightly different props. One RTK-backed wrapper replaced three copies and simplified focus-area navigation tests.
Performance impact
The migration was not sold as a performance project, but performance improved once duplicated data-fetching paths disappeared.
The biggest wins came from removing accidental double fetches and reducing cache disagreement:
- Screens no longer mounted Apollo hooks and Redux-backed requests for the same data.
- Customer-tenant and active-account switches invalidated one predictable set of RTK tags instead of resetting one client and manually refetching another.
- Cold-start work became easier to reason about because auth, configuration, and module data all flowed through the same Redux lifecycle.
- Bundle and runtime overhead dropped slightly as migrated modules stopped importing Apollo-specific wrappers and generated files.
The user-visible effect was less dramatic than a rewrite headline, but more important operationally: fewer stale screens after context changes, fewer unnecessary network calls, and more predictable loading states during app startup.
Cache invalidation lessons
The bugs we hit most often during migration:
| Scenario | Apollo behavior we had to match | RTK approach |
|---|---|---|
| Customer-tenant switch | Implicit refetch on client reset | invalidateTags on all customer-scoped tags |
| Active-account switch | Manual refetch in screen useEffect |
Tag invalidation from the account switch action |
| Pull to refresh | refetch() on query |
refetch() or tag invalidation — pick one per screen |
| Optimistic updates | Apollo cache writes | RTK onQueryStarted + patch |
The active-account switch case taught us to treat tags as domain events. When the user switches account context, we invalidate feature-level tags, homepage tags, and module-specific tags in one dispatch — not scattered refetches in individual screens.
What we did not migrate yet
At the time of this writing, some legacy Apollo usage remained in low-traffic paths. That is acceptable. The migration epic explicitly tracks remaining modules and prioritizes by churn and incident history.
We also started an RTK codegen rollout to replace hand-written endpoint definitions — but codegen is an acceleration step, not a prerequisite. Manual endpoints taught us which tag shapes and error normalizers the generated code must produce.
AI and developer guidance
We added team guidance: default to RTK Query for new GraphQL data in mobile. Apollo is legacy unless you are extending an unmigrated module and cannot split the PR.
We also added an AI coding skill for this migration. That mattered because RTK Query is easy to misuse in small ways that look correct in a generated diff:
- creating a new API slice when the module already has one
- omitting
providesTagsorinvalidatesTags - inventing tag names instead of using the shared domain tags
- calling
refetch()from screen effects when a domain-level invalidation helper already exists - duplicating request types instead of reusing generated or module-owned types
The AI guidance is intentionally narrow. It does not say "always generate an endpoint." It says: check the existing module API first, follow the tag taxonomy, preserve the loading/error contract, and add invalidation at the domain event boundary. That made AI-assisted changes safer for developers who were not deep in the migration details.
Guidance for other teams
If you are planning a similar migration:
- Start with a read-heavy, bounded module.
- Invest in tag design early — it is harder to retrofit than endpoint URLs.
- Keep Apollo and RTK off the same screen — dual-fetching hides cache bugs until production.
- Measure customer-tenant switch and cold-start flows after each migration; those exposed most of our regressions.
- Add examples or AI instructions for endpoint shape, tag names, and invalidation rules before asking a broader team to copy the pattern.
Moving off Apollo was less about choosing a better library and more about one cache, one mental model, one invalidation story for a large team shipping weekly. The performance improvement came from that same simplification: fewer duplicated requests, fewer stale cache states, and less startup behavior split across competing clients.