Onboarding

How to Track Onboarding Drop-Off in Mobile Apps

Model your onboarding as steps, track them reliably in React Native, read the funnel, find the step where people quit and check whether your fix helped.

On this page
  1. Why people abandon onboarding
  2. Model the flow before you write code
  3. A flow, its steps and three events
  4. Version the flow when the steps change
  5. Tracking onboarding steps in React Native
  6. Pitfalls that skew the numbers
  7. Reading the onboarding funnel
  8. Finding out why people stop at a step
  9. Time spent on the step
  10. Taps, rage taps and errors on that step
  11. Segment before you redesign
  12. Improving completion without fooling yourself
  13. Doing this with Riveora

Onboarding is the first few minutes someone spends in your app, and for many people it’s also the last. They install, tap through a couple of screens, hit something they don’t like and close the app. You never hear about it. Your install numbers look fine and your active users don’t, and nothing in between tells you why.

This article walks through how to measure that gap properly: how to model an onboarding flow as data, how to track each step in a React Native app without the usual counting bugs, how to read the resulting funnel, and how to work out why people stop where they stop. At the end there’s a short section on how Riveora handles it, but everything before that works with whatever analytics tool you use.

Why people abandon onboarding

Before you instrument anything, it helps to know what you’re likely to find. Most onboarding drop-off comes from a handful of causes, and each one leaves a slightly different mark in the data.

  • Too many steps. Every screen is another chance to leave. A flow that asks for a name, a goal, a schedule, a theme and a notification preference loses a few people at each one, and those losses compound.
  • Asking too early. A sign-up wall or a permission prompt before the person has seen anything useful asks for trust you haven’t earned yet. Both Apple’s onboarding guidelines and Android’s guide to requesting permissions push you to ask in context, when the person uses the feature that needs it.
  • Unclear value. Three slides of marketing copy don’t tell anyone what the app will do for them. People skim, tap “Next” to get through it, and leave when the payoff still isn’t obvious.
  • Slow or broken steps. A sign-in that spins, a “Continue” button that does nothing until a field is valid with no hint as to why, a request that fails on a weak connection. These look like drop-off but they’re bugs.
  • A paywall at the end. Showing a paywall as the last step can be a perfectly good business decision, but if closing it isn’t obvious, or there’s no way past it, people who would have stayed as free users leave instead.

You can’t tell which of these applies by looking at the flow yourself. You need numbers per step.

Model the flow before you write code

Onboarding data is most often useless because it was never modelled: a few screen events, and months later nobody can say which belong to onboarding, in what order, or what changed between releases. Decide the shape first.

A flow, its steps and three events

Treat onboarding as a named flow made of ordered steps, and describe it with three events:

EventWhenProperties
onboarding_startedThe first onboarding screen appearsflow_id
onboarding_step_viewedEach step appears to the personflow_id, step_id, step_index
onboarding_completedThe person reaches the end of the flowflow_id

The step_id is a stable, readable name (pick_goal, enable_notifications), not the screen title, which you’ll reword. The step_index is the step’s position, starting at 0. The id tells you what the step is; the index tells you how far someone got, which is what you’ll sort and compare on.

Decide what “completed” means up front and write it down. For most apps it should mean “reached the main part of the app”, not “bought a subscription”. If the flow ends on a paywall, someone who closes the paywall and lands on the home screen has completed onboarding. Whether they paid is a separate question, and mixing the two hides both answers. (The article on paywall analytics covers that side.)

Version the flow when the steps change

When you add, remove or reorder steps, the old indexes stop meaning the same thing: “step 2” used to be notifications and is now account creation. If both versions report under the same flow id, the funnel blends two flows into one that never existed.

The fix is cheap: put a version in the flow id (new_user_v1, new_user_v2) and bump it whenever the structure changes. Copy tweaks on a step don’t need a new version. Adding or removing a step does. You can then compare the two flows side by side instead of guessing which release a drop came from.

Tracking onboarding steps in React Native

The naive version is a track() call in a useEffect on each onboarding screen. It works on the happy path and quietly miscounts everywhere else. A small wrapper that remembers what it already sent fixes most of the problems in one place. This one takes your analytics tool’s track function, so it isn’t tied to any vendor:

onboarding-tracker.tsts
import AsyncStorage from '@react-native-async-storage/async-storage';

type Props = Record<string, string | number | boolean | null>;
type Track = (name: string, props: Props) => void;

interface Progress {
  started: boolean;
  completed: boolean;
  handled: string[]; // steps already viewed or skipped
}

export function createOnboardingTracker(flowId: string, track: Track) {
  const key = `onboarding_progress:${flowId}`;
  let progress: Progress = { started: false, completed: false, handled: [] };

  // Restore progress so a relaunch resumes the same attempt.
  const ready = AsyncStorage.getItem(key)
    .then((raw) => {
      if (raw) progress = JSON.parse(raw) as Progress;
    })
    .catch(() => {});
  const save = () => AsyncStorage.setItem(key, JSON.stringify(progress)).catch(() => {});

  const firstTime = async (stepId: string) => {
    await ready;
    if (progress.completed || progress.handled.includes(stepId)) return false;
    progress.handled.push(stepId);
    void save();
    return true;
  };

  return {
    async start() {
      await ready;
      if (progress.started) return; // resumed after the app was closed
      progress.started = true;
      void save();
      track('onboarding_started', { flow_id: flowId });
    },
    async stepViewed(stepId: string, stepIndex: number, props: Props = {}) {
      if (!(await firstTime(stepId))) return;
      track('onboarding_step_viewed', { flow_id: flowId, step_id: stepId, step_index: stepIndex, ...props });
    },
    async stepSkipped(stepId: string, stepIndex: number) {
      if (!(await firstTime(stepId))) return;
      track('onboarding_step_skipped', { flow_id: flowId, step_id: stepId, step_index: stepIndex });
    },
    async complete() {
      await ready;
      if (progress.completed) return;
      progress.completed = true;
      void save();
      track('onboarding_completed', { flow_id: flowId });
    },
  };
}

Then fire the step event when a screen gains focus, not when it renders. With React Navigation that’s useFocusEffect; Expo Router exports the same hook from expo-router (see its hooks reference).

use-onboarding-step.tstsx
import { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native'; // or from 'expo-router'
import { createOnboardingTracker } from './onboarding-tracker';
import { analytics } from './analytics'; // your analytics tool

export const onboarding = createOnboardingTracker('new_user_v2', (name, props) =>
  analytics.track(name, props),
);

export function useOnboardingStep(stepId: string, stepIndex: number) {
  useFocusEffect(
    useCallback(() => {
      void onboarding.stepViewed(stepId, stepIndex);
    }, [stepId, stepIndex]),
  );
}

// In a step screen:
// useOnboardingStep('pick_goal', 1);

Pitfalls that skew the numbers

Each of these is common, and each one makes your funnel lie in a specific direction.

  • Steps firing again on re-render or going back. An effect that depends on state runs again whenever that state changes, and React’s Strict Mode runs effects twice in development. A focus effect runs again when someone taps back to an earlier step. Without de-duplication, a person who goes back and forth looks like several people, and later steps look worse than they are. The handled list above records each step once per attempt.
  • Optional and skipped steps. If a step can be skipped (“Maybe later” on notifications), a person who skips it never sends a view for it. Record the skip as its own event and keep the step’s index, so the next step’s count isn’t compared against a step most people never saw.
  • Conditional branches. A fitness app might ask people who connect a health app for fewer details than people who enter them by hand. Give each branch step its own step_id, keep the indexes increasing along each path, and add a property such as branch: 'manual' if you want to compare the paths. Don’t reuse a step id at two different positions.
  • The app being killed mid-flow. People get a phone call, switch away, and the system kills the app. If your onboarding resumes where they left off but your tracking calls start again, you’ll count one person as two attempts, one of them abandoned. Persist progress (as above) and make sure your analytics tool keeps queued events on disk so the ones recorded just before the kill still arrive.
  • Reinstalls. Most analytics tools give each install a random anonymous ID, and a reinstall usually clears local storage too, so someone who reinstalls and onboards again counts as a new person. Once a person signs up, attach your own user ID so their history joins up across installs and devices.
  • Test devices. Your team goes through onboarding dozens of times while building it, rarely the way a real user would. In a small app that’s enough to move the numbers. Keep internal builds out of production data, or at least make them easy to filter.
Test the tracking, not just the flow

Before shipping, go through onboarding once normally, once going back and forth, once skipping every optional step and once killing the app halfway. Then check that each run produced exactly one start, one view per step, and at most one completion.

Reading the onboarding funnel

Once data comes in, count unique people at each step, not events. Here’s a worked example with made-up numbers for a habit tracker’s onboarding, over the same 30 days:

StepPeople who reached itFrom previous stepFrom start
0 · welcome1,000–100%
1 · pick_goal91091.0%91.0%
2 · enable_notifications86094.5%86.0%
3 · create_account56065.1%56.0%
4 · paywall43076.8%43.0%
Completed (home screen)30069.8%30.0%

Example data, not a benchmark. Two numbers matter here, and they answer different questions.

Overall completion (300 of 1,000, 30%) tells you how the flow is doing as a whole. It’s the number to track release over release. Step-to-step conversion tells you where the loss happens. In this example the account step keeps 65% of the people who reach it, so 300 people stop there, more than at any other step. The paywall step looks almost as bad in percentage terms, but it loses 130 people, not 300.

Look at the absolute numbers before the percentages. A step that converts at 50% but only sees 40 people matters less than one at 80% that 900 people pass through. And be wary of small samples: with a few dozen people per week, a swing of ten points can be noise. Compare periods of the same length and wait until each step has seen at least a few hundred people before you draw conclusions.

Also check the gap before the first step. If 1,100 people sent onboarding_started but only 1,000 saw step 0, a hundred people left before the first screen even appeared. That usually points to a slow launch, a crash or a blocking request, not to onboarding content.

Finding out why people stop at a step

The funnel tells you where. These signals help with why.

Time spent on the step

Measure the time between one step and the next, using the median rather than the average (a few people who put the phone down for an hour drag an average to nonsense). People who leave a step after 3 seconds probably don’t want to give what it asks for. People who leave after 40 seconds are probably confused or waiting on something slow.

Taps, rage taps and errors on that step

Look at what people do on the step where they stop. Repeated quick taps on the same spot (rage taps) usually mean a button that doesn’t respond, or responds without visible feedback. Taps on things that aren’t buttons mean something looks tappable and isn’t. Failed requests and JavaScript errors on that step are often the whole explanation: a sign-in provider that errors on one Android version will show up as “drop-off at create_account” until you look closer. The guide to tracking user behavior in React Native goes into tap and error tracking in more detail.

Segment before you redesign

Split the funnel by platform, app version and, if you have it, where people came from. A drop that only happens on Android, or only since the last release, is a bug or a regression, and redesigning the step won’t fix it. A drop that’s the same everywhere is more likely a design problem. Segmenting by version only works if every event carries the app version, so add it to your events from day one.

Improving completion without fooling yourself

Once you know which step to work on, the fixes usually follow from the causes earlier:

  • Remove steps you don’t need yet. Ask for a theme or a reminder time later, when it’s relevant.
  • Move sign-up and permission prompts after the person has done one useful thing, and explain the permission on your own screen before the system prompt appears.
  • Show the product instead of describing it: let someone create their first habit or save their first recipe.
  • Fix slow and broken steps first. They’re usually the cheapest wins.
  • Make the way past a final paywall obvious if free use is allowed.

Then check whether the change actually helped. Ship it as a new flow version, and compare the new flow with the old one over periods of the same length, after enough people have gone through the new one. Change one thing at a time where you can. If you remove a step, reword two others and move the paywall in one release, and completion goes up by eight points, you won’t know which change did it, or whether one of them made things worse and was hidden by the others.

Finally, look one step beyond onboarding. A shorter flow that gets more people to the home screen but fewer of them coming back the next day hasn’t improved anything. Pair completion with a simple next-day or next-week return check, and with the key action of your app. The article on essential analytics events covers which ones to track.

Doing this with Riveora

Riveora can’t know which of your screens are onboarding, so your app marks the start, each step and the finish with three calls from the SDK:

onboarding.tsxtsx
import { Riveora } from '@riveora/react-native';

// When onboarding begins
Riveora.onboarding.start('new_user_v2');

// Each step as it appears (step index starts at 0)
Riveora.onboarding.stepViewed('new_user_v2', 'welcome', 0);
Riveora.onboarding.stepViewed('new_user_v2', 'pick_goal', 1);
Riveora.onboarding.stepViewed('new_user_v2', 'enable_notifications', 2);

// When the user finishes
Riveora.onboarding.complete('new_user_v2');

These send onboarding_started, onboarding_step_viewed and onboarding_completed, and each method also takes an optional properties object as its last argument. Pass the flow id to complete() as well; without it the flow id defaults to default. If you use a wrapper like the one above, call these methods from its start, stepViewed and complete. A skipped step can go out as a custom event with Riveora.track(). The onboarding docs recommend a new flow id, such as new_user_v2, whenever the steps change.

The Onboarding page in the dashboard shows, for each flow, how many people started and how many finished, with the completion rate. Below that, every step is listed in order with how many people saw it and how many stopped there, and the step where the most people gave up is called out at the top. It also tells you how many people started but left before the first step appeared. You can switch the period between the last 7, 30 or 90 days, the last year and all time. Each flow id gets its own card, so new_user_v1 and new_user_v2 can be compared directly.

A few details that affect how you instrument it:

  • Steps are counted per person, so a step viewed twice by the same person (going back, a re-render) still counts once. Where someone “stopped” is worked out from the highest step index they reached, so keep indexes increasing along the flow.
  • A flow can span several visits: someone who stops on a step today and finishes tomorrow counts as finished.
  • People are counted by their anonymous install ID, or by your user ID once you call Riveora.identify(). Attempts made on different installs or devices where the person signed in count as one person.
  • Events are queued on the device and sent in small batches, so events recorded just before the app is closed are kept and sent later. This needs the storage prop from the quickstart; without it, every launch also looks like a new user.
  • beforeSend on RiveoraProvider can return null to drop an event, which is one way to keep internal test builds out of the numbers.

For the “why”, use the screens themselves. If each step is its own route, it gets a page under Screens with the typical time spent there, taps per visit, how often people leave the app from it, rage taps, a tap map, where people came from and went next, and recent visitors whose full visit you can replay. If your steps are slides inside a single screen, mark each one with Riveora.screen() so it gets its own page. On slides, a button that only one slide has (“Get started”) isn’t labelled automatically; name it with RiveoraLabel (see automatic capture). The Users page shows each step, and finishing, in a person’s journey, and the Overview shows onboarding completions for the last 7 days. To confirm the events arrive while you test, open Live events.

All articles
Track Onboarding Drop-Off in Mobile Apps · Riveora