A spinner that never resolves is a load that started and never finished, and it fails one of three ways: the request never comes back, the response comes back and nothing handles it, or the loading flag is set and never cleared. Which one it is you cannot read off the spinner, only off the record of the run.
The spinner that never lands
On your machine the screen loads. In the field, a slice of users open checkout or a restaurant list and the spinner just runs. No crash, no error toast, no red in your dashboards, only a control that spins until they force-quit. It never reproduces at your desk: the debug build and the simulator both load instantly, and the one time you saw it yourself a retry cleared it. The reports say “it never loads” and give you nothing more, because a spinner is the one UI state that emits no signal on its own.
Why a load starts and never finishes
The first way is that the request never settles in time. fetch() in React Native has no app-level timeout you control: its native NSURLSession backing only gives up after 60 seconds of silence, and its resource timeout defaults to a week, so a hung or trickling connection leaves the promise pending long enough to read as forever unless you wrap it in an AbortController. A backend that accepts the socket and never writes a response, an endpoint stuck behind a slow query, or a proxy that holds the connection open all produce the same thing: your await sits there, the .then that flips isLoading back to false never runs, and the spinner keeps turning.
The second way is that the response arrives and no code path clears the spinner. An empty catch {} swallows a rejected promise, so the failure clears nothing and re-throws nothing. A JSON.parse throws because the backend returned an HTML error page or a truncated body, and control jumps to a catch that logs and returns without ever setting isLoading = false. Or the payload has a shape your success branch does not expect: the API returns { error }, an empty array, or a 204, and your reducer keys off data.items, which is undefined, so no state updates and the loading flag is left alone. A missing empty-state branch does the same by omission, when you render the spinner while items === null but only ever assign items on the happy path.
The third way is a loading flag with no route back to false. isLoading is set true on mount or on the tap, and cleared only inside the success closure, with no finally. Every branch that is not success then leaves it stuck: a thrown error, an early return, a component that unmounts and remounts and refires the request, or a race where a stale response is discarded and the fresh one never lands. This is the most common cause and the easiest to miss in review, because the happy path is correct and the code reads fine.
A fast network does not save you from the last two. They fire the same regardless of how quickly the response comes back, because they are control-flow bugs in how the app handles what it gets, not in how long it waits.
Why the spinner tells you nothing from the outside
From the outside all three causes are the same picture: an identical spinner, no crash, and no error the user ever sees. Proxyman or Flipper would show you the waterfall and the console, but only if you can get the hang to happen locally, and you often cannot: it depends on backend state (an endpoint that only hangs for a particular account, or returns the wrong shape under one data condition) and on a specific build (the release variant pointed at production, not your dev server). Your local backend responds quickly and returns well-formed JSON, so the request-never-returns and response-dropped cases never even fire.
The one thing that separates the three is a record of an actual run. The network waterfall does the first cut. A request still in flight, with no status and no response body, is a hung request: the app is waiting, and you are in cause one. A request that returned, a 200 with a body or a 500 or a 204, sitting beside a screen that is still spinning, means the response arrived and the UI dropped it, which puts you in cause two or three. That single read splits “request hung” from “response dropped.”
From there the device logs finish the diagnosis, but only for errors that were never caught. An unguarded SyntaxError from a parse throw, or React Native’s Possible Unhandled Promise Rejection warning, lands in the logs even though the user saw nothing. An error your empty catch ate leaves no trace at all, and that silence is itself the tell: if the logs are quiet and the response arrived, nothing threw, the flag simply never flipped, which is the signature of cause three. The video and the timeline’s step-level grounding screenshots tie the spinner to the precise tap that fired the request and show that it never resolves across the whole run, rather than merely being slow, and the before-and-after screenshots read identical minutes apart. Together they turn an un-actionable “it never loads” into “the POST is still pending” or “the POST returned 200 and the screen is still spinning.”
Run it on a cloud device and read the waterfall
This is a symptom you run rather than infer. Boot a cloud device on the model and OS build your affected users are on, install the release build pointed at production (or sign in as the account whose data trips the bad branch), drive uberteats to the screen that hangs (checkout, or a list fetch), and read the record. Revyl runs that build against your live backend and captures what happens; it does not shape or throttle the network, so a hang that only appears on a degraded connection is one you capture from the field first. A hang rooted in your backend or your own missing timeout, and every dropped-response and stuck-flag case, reproduces on the first run. Then the waterfall tells you pending versus returned, the logs surface the swallowed throw, and the video shows the spinner still turning after the response arrived.
revyl auth login
revyl build --platform ios
revyl test run uberteats-checkout --build-id <id>
revyl device start --platform ios