AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile GraphQL Crash Debugging: Apollo & Relay

NFNourin Mahfuj Finick··8 min read

Mobile GraphQL crash debugging is one of the least forgiving corners of modern app development. GraphQL clients such as Apollo iOS, Apollo Android, Apollo Kotlin, and Relay hide a lot of complexity behind clean query syntax, but when the schema, cache, or transport layer changes underneath them, that complexity surfaces as a hard crash: a fatalError in Swift, an IllegalStateException in Kotlin, or a silent nil that blows up a force-unwrap. Unlike REST, where a malformed response usually just produces a parsing error you can catch, a GraphQL client crash often originates deep inside a normalized cache or a generated model, far from the network call that triggered it. In this guide we walk through the seven crash root causes we see most often in production and show you how to reproduce and fix each one.

Why GraphQL Clients Crash on Mobile

A GraphQL client does three things at once: it issues queries, it parses and validates responses against a schema, and it writes results into a local store so your UI can subscribe to cached data. Each of those layers has its own failure mode. The Apollo client cache configuration explains how the normalized cache uses __typename and id fields to build stable object keys, and when those fields are missing the client can no longer merge results safely. Relay goes further with compiler-enforced fragments, which catches many problems at build time but introduces its own class of runtime failures when the compiled artifacts drift from the schema. Understanding which layer is crashing is the fastest way to a fix, so start by reading the top of the stack trace and asking whether the frame belongs to the cache, the codegen, or the transport.

Cache Normalization Crashes

The most common crash we triage is in the normalized cache. Apollo's InMemoryNormalizedCache (and Relay's equivalent store) builds a key for every object from its __typename and id. When a query returns an object without an id — a common situation with list items or nested inline types — the client falls back to the object's path in the query, and that path changes between fetches. The result is duplicate cache keys and CacheKey collisions that surface as an assertion failure or an IllegalStateException on the main thread.

query GetFeed {
  feed {
    items {          # no id here -> cache key collision risk
      title
      author {
        name
      }
    }
  }
}

The fix is to configure keyFields or a typePolicy so the cache can generate a stable identity, or to add id and __typename to every type in your schema. Apollo's docs recommend using the possibleTypes and typePolicies APIs for exactly this case, and we cover the sibling problem of stale data in our guide on mobile cache invalidation crashes.

val apolloClient = ApolloClient.Builder()
    .serverUrl("https://api.example.com/graphql")
    .normalizedCache(
        MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024),
        cacheKeyGenerator = object : CacheKeyGenerator {
            override fun cacheKeyForObject(obj: Map<String, Any?>): CacheKey? {
                val id = obj["id"] as? String ?: obj["uuid"] as? String
                return id?.let { CacheKey(it) }
            }
        }
    )
    .build()

Codegen Type Mismatches

Apollo Kotlin and the apollo-ios-cli toolchain generate strongly typed models from your schema at build time. Those models assume the schema is the source of truth, so when the server deploys a change before the client regenerates, the generated model silently deserializes null into a non-null Kotlin or Swift property. The crash appears later, when your UI reads post.author.name and the compiler's promise of a non-null author turns out to be false.

// Generated model assumes author is non-null
data class Post(
    val id: String,
    val title: String,
    val author: Author  // generated as non-null
)

If the server returns "author": null during a partial rollout, the generated adapter throws a NullPointerException deep in the parsing layer. The Apollo Kotlin documentation stresses regenerating models in CI whenever the schema hash changes, and treating schema drift as a release-blocking issue rather than a runtime surprise. You can read more about decoding failures and serialization in our mobile data serialization crash debugging post.

Force-Unwrap and fatalError Crashes

Swift developers often write let author = post.author! because the generated model promised a non-null field. The moment the server omits that field — because a resolver returned null, a field was deprecated, or a partial-error response came back — the force-unwrap traps and the app terminates. GraphQL's non-null contract is a schema-level guarantee, but it is only as reliable as every resolver behind it, and mobile clients have no way to enforce it at runtime.

let author = post.author!  // fatalError when the server omits the field

The resilient pattern is to never force-unwrap query results. Treat every generated optional as optional, use if let or guard let, and log a breadcrumb with the query name and operation id before bailing out. Apollo's iOS documentation recommends enabling GraphQLResult error handling so partial data plus errors are surfaced together instead of being swallowed into a trap.

Pagination and Cursor Edge Cases

Relay-style pagination depends on cursor and pageInfo.hasNextPage being present and well-formed on every page. When a resolver returns hasNextPage: true but an empty edges array, or a null cursor on the last item, a client that blindly slices by cursor or indexes into edges will crash with an out-of-bounds error or a nil cursor dereference.

query Feed($first: Int, $after: String) {
  feed(first: $first, after: $after) {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

The defensive fix is to check pageInfo.endCursor for nil before requesting the next page and to guard the edges array against empty states. Relay's pagination container does much of this for you, but only if the server honors the cursor spec. The GraphQL specification on connections defines the exact shape of pageInfo, and it is worth auditing your backend against it before blaming the client.

Subscription & WebSocket Reconnect Races

Live features built on subscription-transport-ws or graphql-ws are a frequent source of mobile crashes. The WebSocket drops when a user loses connectivity, and if the client reconnects while the old subscription is still held in memory, you get a reconnect race: duplicate subscription ids, a re-auth handshake that fires twice, and eventually a crash from an unclosed stream. Memory leaks from never-unsubscribing live queries compound the problem over a long session.

const wsLink = new GraphQLWsLink(createClient({
  url: 'wss://api.example.com/graphql',
  retryAttempts: 5,
  shouldRetry: () => true
}));

The graphql-ws library documentation recommends a single shared client with explicit dispose() and a retry backoff, plus a re-auth callback that re-issues the connection init message exactly once. On mobile, tie subscription lifecycle to the view controller or composable that owns it, and always cancel in onDisappear/onCleared. Our mobile WebSocket connection crash debugging guide walks through the reconnect race in more detail.

Schema Evolution and Breaking Changes

When a backend team removes a field, changes an enum value, or deprecates an argument, every shipped client that still queries the old shape is a latent crash. GraphQL introspection means the server can tell you what changed, but it does not protect a client that never re-introspects. The failure mode is a decode error: the generated adapter cannot map a response field to a model, and depending on the client's strictness it either throws or silently drops the data and leaves a nil for your UI to crash on.

The most effective defense is a schema-registry diff in CI that fails the build when a breaking change is detected, combined with versioned queries so older clients keep working until you can force an update. This is the same class of problem as any API breaking change, and we cover the broader pattern in debugging mobile crashes from API breaking changes.

Fragments and Type Conditions

Inline fragments on interfaces and unions are a subtle crash source. When a fragment specifies ... on Photo { url } and the runtime object is actually a Video, the client has no fields to read and the generated reader returns a partially populated model. Code that assumes the fragment matched will then hit a missing property. Relay's compiler catches many of these at build time by requiring exhaustive type coverage, but only for fragments it knows about.

fragment MediaFields on Media {
  ... on Photo { url width }
  ... on Video { duration }
}

The fix is to add a fallback ... on case or a __typename switch so every concrete type is handled, and to run the Relay compiler as a mandatory build step. Type-condition crashes are easy to miss in QA because they only fire for a specific item shape, so make sure your crash reporting captures the full query and the __typename of the object that failed.

A Crash Reporting Safety Net

Even with defensive code, mobile GraphQL clients will hit edge cases you cannot predict, which is why a crash reporting layer is the last piece of the puzzle. Capture the operation name, the query document, the __typename values involved, and the full response (redacting any PII) so you can reproduce cache and codegen failures without a debugger attached to the device. The fastest way to fix a GraphQL crash is to see the exact query and the exact response that produced it, side by side.

BugsPulse gives you that visibility with privacy-first mobile crash reporting that captures full context without raw PII. If you are still flying blind on GraphQL client crashes, start capturing them today at bugspulse.com and get your crash rate under control.

Ready to see it in action? Create a free account at app.bugspulse.com/register and start debugging your Apollo and Relay crashes with full context in minutes.