DEV Community

Cover image for Two backends, one client? RTK Query vs Apollo in React Native
Warren de Leon
Warren de Leon

Posted on Originally published at warrendeleon.com

Two backends, one client? RTK Query vs Apollo in React Native

📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.

Post 9 closed on a promise: the stack stays the same and the backend splits in two, REST for the list, GraphQL for the type badges, one client and one cache. This post builds it.

The question mark in the title is deliberate. During the transition, with a GraphQL backend arriving while REST is still live, one client wins almost regardless of which one it is, and if your app already runs RTK Query that settles it. At the destination, with GraphQL everywhere, the answer turns on two things you can check against your own app: whether you have a federation constraint, and whether your schema is relational enough that a normalised cache earns its keep. A normalised cache stores each entity once, one Pokémon in one slot, so every view that references it reads the same copy. This series has federation, so RTK Query stays the pick here. Change the context and the answer changes, and this post says where.

📊 Diagram: view it on warrendeleon.com

The left shape is the build: REST and GraphQL feeding one baseApi cache under one tag graph, with the host's Refresh button untouched. The right shape is two clients with two caches and no edge between them. That missing edge is what this post is really about.

A second backend arrives

The API team stands up GraphQL. For screens that need related data it can replace several round trips with one, which is where the speed-up comes from rather than from the protocol itself, and it is where the backend is heading. REST is not going away this quarter, or the next. For a while, often a long while, both are live, and every screen is served by one or the other.

The hazard is running two data-fetching clients at once, whichever two you pick. Two clients keep two caches, and the two do not coordinate on their own. A mutation that goes out over GraphQL updates the GraphQL cache, and the update never reaches the REST cache holding the same record. The user edits a Pokémon on the screen served by GraphQL, switches to the list served by REST, and sees the old value. No exception, no failed request, no warning. Just a screen that is quietly wrong until something forces a refetch. Mid-cutover, when the two backends overlap most, is exactly when it does the most damage.

So the transition question is how few caches to run, and the cheapest answer is one.

One slice, two protocols

RTK Query makes one cache serve both protocols, in the slice this series has run since post 6. Start from post 8's tag:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-08-client-state
Enter fullscreen mode Exit fullscreen mode

The list already fetches its rows over REST. It gains a second server-state need, a type badge on each row, served over GraphQL. Two dependencies first, in the list remote only. graphql stays on 16 because graphql-request declares a peer range of 14 to 16 while npm's latest is 17:

( cd apps/list && npm install graphql@16.14.2 graphql-request@7.4.0 )
Enter fullscreen mode Exit fullscreen mode

Then the endpoint, in the same api slice:

const typesApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonTypes: build.query<Record<number, string[]>, void>({
      async queryFn() {
        try {
          const raw = await request(GRAPHQL_URL, POKEMON_TYPES);
          return { data: parsePokemonTypes(raw) };
        } catch (err) {
          return {
            error: {
              status: 'CUSTOM_ERROR',
              error: err instanceof Error ? err.message : 'Invalid GraphQL response',
            },
          };
        }
      },
      providesTags: ['PokemonList'],
    }),
  }),
});

export const { useGetPokemonTypesQuery } = typesApi;
Enter fullscreen mode Exit fullscreen mode

An api slice has exactly one baseQuery, and the list's is fetchBaseQuery on the REST base. A second protocol against a different URL comes from a queryFn, which RTK's own docs list for "one-off queries that use a different base URL". graphql-request posts the query, and the same file carries what the excerpt leaves out: the bounded, ordered GraphQL query itself, the Zod schema that polices ids and type names, parsePokemonTypes, and the screen integration. Rather than print all of it, land your tree on the finished state:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-10-two-backends /tmp/pokedex-ref-10
cp -R /tmp/pokedex-ref-10/. .
Enter fullscreen mode Exit fullscreen mode

The response is validated with its own Zod schema at the seam the REST list already guards, held constant so the comparison swaps only the protocol. The schema lives in the list app rather than the contract package, because a data definition travels with the domain that owns it (post 7's rule).

PokéAPI's GraphQL lives at graphql.pokeapi.co/v1beta2. The older v1beta schema is retired, and with it the pokemon_v2_ field prefix that fills every pre-2025 tutorial. Asking for pokemon_v2_pokemon today returns field 'pokemon_v2_pokemon' not found in type: 'query_root'. The working shape is the unprefixed one, checked live against the endpoint while writing this.

Two things stay deliberately absent. There is no contracts bump: the endpoint injects into the existing baseApi, and it declares the tag the list already owns. And graphql-request stays out of the Module Federation shared map, exactly as Zod does. It is a value-only library with no instance identity to share, so it rides as the list remote's own dependency. Which protocol served a row stays the remote's private business, invisible to the host and to every other remote.

PokéAPI's GraphQL is rate-limited to 100 calls an hour per IP, where REST is fair-use only. RTK Query's caching absorbs it in normal use. A run of cold reloads can still hit the cap, and a 429 mid-build is the limit, not a bug.

One tag across the cutover

The host's Refresh button has dispatched invalidateTags(['PokemonList']) since post 6, and it holds no reference to either endpoint. Both the REST list and the GraphQL types query provide 'PokemonList', so one press refetches both. React Native DevTools' Network panel shows it happening: one tap lands both requests together, and the v1beta2 preview shows the type data arriving over GraphQL.

React Native DevTools' Network panel: an empty log, then one Refresh tap lands two requests together, the GraphQL v1beta2 call and the REST pokemon list call, and the GraphQL row's preview shows the pokemontypes data arriving

That is the transition thesis in one tag: whichever client you keep, keep one. Nothing crosses automatically, though. A mutation clears the other protocol's cache when it names the tag they both provide, and only then; the shared graph is what makes that possible, not what performs it.

The same race is visible on the device: on a cold start the REST rows land first, and the GraphQL badges arrive a moment later as each query fills the shared cache:

🎞️ Animated demo: watch it on warrendeleon.com

The badges also degrade quietly. Point the GraphQL endpoint at an unreachable host and the rows still render, the badges are simply absent, and nothing moves on the screen.

The badges make GraphQL's own pitch concrete too. Types over REST would mean 151 detail calls, one per row, because the list endpoint returns only names and URLs. Over GraphQL it is a single query. Call that what it is, client request fan-out rather than N+1: the classic problem describes a backend repeating a data access per row, while this is one client making a request per row because the endpoint gives it nothing else. Fewer round trips is the win, and it is a point for the protocol rather than for either client.

What Apollo does that this can't

RTK Query treats a GraphQL response as data to cache by endpoint, the same as any REST payload. Apollo does something RTK Query does not attempt: with its default InMemoryCache it normalises. Each object with a __typename and an id gets one slot in the cache, and every query that references it reads that slot. A mutation whose response carries the same type and id updates the entity everywhere it appears, with no refetch.

That is real, and it is the honest reason to reach for Apollo. It is also narrower than the pitch. Auto-consistency covers modifying an entity already in the cache. It does not cover growing a list. In Apollo's own words, "a newly cached object isn't automatically added to any list fields that should now include that object", so a new row still needs an update function or a refetch.

The rest of Apollo's case holds up on its own terms. Fragment colocation lets a component declare the fields it needs and compose them upward by plain template-literal interpolation, with no build step; code generation is optional, and earns its place when you want typed results. Cache redirects can serve a detail view straight from data a list already fetched, but only when every field the detail query asks for is already in the cache. One extra field and the whole query hits the network. Subscriptions and @defer are supported, and @defer needs an incremental-delivery handler plus a streaming-fetch polyfill on React Native, so it is "supported, with setup" rather than free.

And the schema matters more than any of this. PokéAPI's own shape, Pokémon referencing types, abilities, species and evolution chains, with the same entities reused across many queries, is exactly the relational shape where normalisation pays for itself. On this data, Apollo's cache is the better fit, and pretending otherwise would be the strawman this series avoids. (Relay sits in the same family. urql belongs there only once you add Graphcache: its default document cache stores whole responses by query, closer to RTK Query's model than to Apollo's. The trade-offs in Choosing are about the normalised family, not any one library.)

What federation does to the choice

Start with the part that surprises people. Apollo has no injectEndpoints, and it needs none. An operation is a document executed against a client, not an artifact registered in a store, so any remote can ship its own queries against a shared client with zero registration machinery. A remote can even extend the cache at mount through cache.policies.addTypePolicies, which is documented public API. There is no official micro-frontend guide (an unanswered community thread and one customer story are the whole record), but the cache API happens to allow it. On the runtime-extensibility axis post 9 cared about, Apollo is not behind.

It also does not share TanStack's context problem. Apollo caches a single React context on the React instance itself, keyed by a well-known symbol, precisely so two copies of the library cannot hand you divergent contexts. The "no client in context" error fires only when React itself is duplicated. The singleton case for @apollo/client under federation is one cache instance and no version skew between the client and its hooks, not the context split.

Federation still tips the choice in two places: the tag graph and the transition. The one tag that refetches across remotes, and across two protocols, is coordination RTK Query has by construction and Apollo leaves to convention. And adopting Apollo mid-cutover means standing up a second cache next to the RTK Query one you already run, the exact two-cache shape from the start of this post, for the length of the migration. Apollo Client 4 also makes rxjs a required peer and moves its React exports, so the bundle gains a new dependency rather than swapping one.

<p>RTK Query</p>
<ul>
  <li><strong>Cache model:</strong> by endpoint, refetch on invalidate</li>
  <li><strong>Cross-protocol invalidation:</strong> one shared tag graph, built in</li>
  <li><strong>Adding queries from a remote:</strong> <code>injectEndpoints</code> at runtime</li>
  <li><strong>Relational, entity-reuse schema:</strong> refetches what it already had</li>
</ul>


<p>Apollo</p>
<ul>
  <li><strong>Cache model:</strong> normalised by <code>__typename</code> + id, patch in place</li>
  <li><strong>Cross-protocol invalidation:</strong> per-client; a second cache to coordinate</li>
  <li><strong>Adding queries from a remote:</strong> documents against the client; nothing to register</li>
  <li><strong>Relational, entity-reuse schema:</strong> the cache pays for itself</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Choosing

No absolute winner, so read down to the case that fits your app.

Transition, both backends live: run one client. If the app is already on RTK Query, and this series is, that decides it. One cache, one tag, no two-cache staleness to police, and it is the common case.

Destination, GraphQL everywhere, with a federation constraint and shared coordinated state: RTK Query stays. The honest price is living without normalisation: tag invalidation refetches where Apollo would patch in place, and updateQueryData covers the few spots where a hand-patched update is worth it. You trade some round trips for one cache and one tag graph across independently shipped remotes.

Destination, GraphQL everywhere, a relational entity-heavy schema, and no federation constraint: Apollo, plainly. The normalised cache is the point of a GraphQL client, and without federation pulling the other way, that is the reason to adopt it. No hedge.

The signals to watch for are two specific days. The day you notice most of your invalidations refetch data the cache already held: normalisation would have saved those round trips. And the day the second backend goes live and two caches disagree on screen with no error to explain it. The first is a nudge toward Apollo. The second is the reason to run one client, and only one, all along.

Next: the design system becomes a federated singleton, one UI package shared at runtime, so every remote renders the same components without shipping its own copy.

Sources

Top comments (0)