DEV Community

Cover image for Designing Fault-Tolerant Onboarding: How to Resume User Progress After Reinstall
FARINU TAIWO
FARINU TAIWO

Posted on

Designing Fault-Tolerant Onboarding: How to Resume User Progress After Reinstall

When building a multi-role platform, onboarding is not a single problem. It is several distinct problems presenting themselves as one. The customer app experience is a clean five-screen flow. The rider app experience is far more demanding, spanning eight screens that cover identity verification, vehicle documentation, biometrics, and photo uploads. When the architecture is poorly designed, the impact goes beyond reduced conversion. It leads to real data loss when riders abandon the process midway and are forced to start again from the beginning.

Working across both experiences reveals a clear progression. The customer flow is straightforward and optimized for speed, while the rider onboarding introduces deeper complexity and stricter requirements. This article reflects that journey. It explores how I approach flow design, how I build systems that allow users to resume progress even after reinstalling the app, and how I evaluate the two dominant onboarding patterns used across the industry today.

Simple and Complex Onboarding

The Real Problem with Multi-Step Onboarding

Designing the screens is not the hard part. Most teams can build a sequence of forms. The real challenge is handling interruption. Users rarely complete onboarding in one sitting. They get distracted, lose connection, their app crashes, their phone dies, or they simply decide to come back later. Sometimes, they uninstall and return weeks after.

In a simple customer flow, this interruption is manageable. Restarting the process costs only a few minutes. In a rider flow, it is a different story. By the time a user reaches the KYC stage, they have already verified their phone number, filled in personal details, and submitted vehicle information. If they are forced to start over because their session expired or their data was not preserved, the experience breaks down. What should have been a continuation becomes a reset, and that is where conversion is lost.

The goal is to make every completed step permanent. Not tied to a session, not tied to a device, but saved in a way that the user can always continue from where they stopped.

Apps like the Uber Driver handle this well. If you leave the onboarding process halfway and return a week later, logging back in takes you straight to where you stopped. Your details are still filled in, your documents remain uploaded, and the system clearly shows what is complete and what is still missing. This is the standard onboarding experience should aim for.

Pattern A - Gated Sequential Onboarding

The first pattern is the traditional approach. Each step after OTP acts as a gate, and the user cannot access the Home screen until every required step is completed. The onboarding behaves like a strict linear flow with no shortcuts or skipping.

The Server as the Source of Truth

The key architectural decision in this pattern is that onboarding state is fully managed on the backend. The app does not try to remember or infer the user’s progress. Instead, it asks the server what has been completed and what is still required, then navigates the user accordingly.

This means that after OTP verification, once an access token is issued, every app launch or login triggers a profile status request. The server responds with the user’s current onboarding state, and the app routes them to the correct step. A typical response might look like this:

// The backend contract - every field has clear meaning

class ProfileStatus {
  final bool isOtpVerified;
  final bool isProfileComplete;   // name, phone, address filled
  final bool isKycVerified;        // identity docs approved by ops
  final String? photoUrl;           // null = never uploaded
  final Vehicle? vehicle;            // null = vehicle not added yet

  const ProfileStatus({
    required this.isOtpVerified,
    required this.isProfileComplete,
    required this.isKycVerified,
    this.photoUrl,
    this.vehicle,
  });
}
Enter fullscreen mode Exit fullscreen mode

Design your null states deliberately: photoUrl == null means "never uploaded". This is different from photoUrl == "" which could mean "uploaded then removed". vehicle == null means "never added" is different from a vehicle object with isDocVerified: false. Each null state carries information. Document these in your API contract.

The Destination Resolver

The logic that decides where to send the user after login is a simple waterfall. Every step is checked in order, and the first incomplete step wins. This function runs in your splash handler or post-login use case:

Future<void> _resolveOnboardingDestination(
  ProfileStatus status,
  BuildContext context,
) async {
  if (!status.isOtpVerified) {
    return _navigate(context, Routes.verifyOtp);
  }
  if (!status.isProfileComplete) {
    return _navigate(context, Routes.addInfo);
  }
  if (status.vehicle == null) {
    return _navigate(context, Routes.addVehicle);
  }
  if (!status.isKycVerified) {
    return _navigate(context, Routes.kyc);
  }
  if (status.photoUrl == null || status.photoUrl!.isEmpty) {
    return _navigate(context, Routes.addPhotos);
  }

  return _navigate(context, Routes.home);
}
Enter fullscreen mode Exit fullscreen mode

This is the entire resumability logic. There is no local step counter, no tracking of the last screen visited, and no complex client-side state machine. The server simply returns a profile status object, and a single routing function translates that response into a navigation decision. Whether the user reinstalls the app, switches devices, or logs in from a web client, they always resume from the correct step because the source of truth lives on the server.

Local Cache as a Hint, Not the Source of Truth

Even with a server-driven approach, caching the last known profile status locally is still useful. Its purpose is not to determine flow, but to improve perceived performance by avoiding a loading state on every app launch.

The approach I use is to render the cached state immediately while a fresh request runs in the background. Once the server response arrives, the app reconciles and updates the UI if the state has changed:

Future<void> loadAndResolve(BuildContext context) async {
  // 1. Render cached route instantly (Better UX performance)
  final cachedRoute = await _prefs.getString('rider_last_route');

  if (cachedRoute != null && context.mounted) {
    _replaceCurrentRoute(context, cachedRoute);
  }

  // 2. Fetch fresh status from server (source of truth)
  final result = await _fetchRiderStatusUseCase.call();

  result.fold(
    (failure) => _handleError(failure),
    (status) async {
      final resolvedRoute = _resolveRoute(status);

      await _prefs.setString(AppKeys.lastRoute, resolvedRoute);

      // 3. Reconcile cached route with server truth
      final isStaleCache = resolvedRoute != cachedRoute;

      if (isStaleCache && context.mounted) {
        _replaceCurrentRoute(context, resolvedRoute);
      }
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

One important navigation rule - Every navigation inside the onboarding resolver must use pushAndRemoveUntil (or go with a full stack replacement in go_router). The user should never be able to press back from the Add Info screen and return to the Sign Up screen. Each completed step should be permanently removed from the navigation stack.

When to Choose Pattern A

Gated sequential onboarding is the right choice when:

  • The app cannot function without complete data. A rider cannot receive deliveries without an approved identity and a profile photo. Letting them reach Home serves no purpose.
  • Regulatory compliance requires it. KYC in financial apps and driver verification in delivery are often legally mandated before the user can transact.
  • The onboarding steps have natural dependencies - KYC often requires a completed profile; photo approval may require a verified identity. The dependency chain makes a linear gate sensible.

Pattern B - Home-First with Progressive Nudges

This pattern has become increasingly common in consumer apps over the last few years. After OTP verification and receiving an access token, the user is taken directly to the Home screen, often with partial access to features. The remaining onboarding steps are no longer blocking gates but are surfaced gradually through banners, nudges, or contextual prompts.

Apps like Duolingo use this approach effectively, and many early-stage fintech apps follow the same model. The underlying principle is simple: time to value is more important than completing all profile data upfront. Letting users experience the product immediately reduces drop-off at the registration stage.

Instead of forcing completion, additional profile information is collected progressively and in context, only when it becomes relevant to the user’s actions or needs.

The Architecture Shift

In this pattern, your onboarding state still lives on the backend and nothing changes there. What changes is the interpretation layer. Instead of redirecting the user to a form when the state is incomplete, the Home screen subscribes to the profile status and renders nudges accordingly:

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final status = context.watch<RiderProfileProvider>().profileStatus;

    return Scaffold(
      body: Column(
        children: [
          // Nudges appear in priority order - most critical first
          if (!status.isProfileComplete)
            OnboardingNudge(
              icon: Icons.person_outline,
              message: 'Complete your profile to go online',
              onTap: () => context.push(Routes.addInfo),
            ),
          if (status.vehicle == null)
            OnboardingNudge(
              icon: Icons.directions_bike_outlined,
              message: 'Add your vehicle to start receiving orders',
              onTap: () => context.push(Routes.addVehicle),
            ),
          if (!status.isKycVerified)
            OnboardingNudge(
              icon: Icons.verified_user_outlined,
              message: 'Verify your identity to unlock all zones',
              onTap: () => context.push(Routes.kyc),
            ),

          // ... rest of home content
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Nudges are prioritized in order of importance. The most critical missing requirement is shown first. As each item is completed, it is removed and the next one takes its place. There is no separate onboarding flow and no navigation reshuffling. The Home screen simply reacts to the same profile status object it already consumes.

Nudges as a Post-Launch Feature Delivery Mechanism

This is where Pattern B becomes especially powerful beyond initial onboarding. Nudges are not just onboarding tools; they become a way to introduce new requirements to existing users without disrupting their experience.

Imagine the rider app has been in production for six months. Early users were onboarded without vehicle insurance verification because it was not part of the original requirements. Now it becomes mandatory. In Pattern A, this creates friction. You either force users back into a gated flow or attempt to reroute them through a modified onboarding process, both of which are disruptive and error-prone.

In Pattern B, the solution is simpler. You introduce a new field in the backend profile response, and the app automatically adapts without requiring changes to the user’s current state or flow.

{
  "isProfileComplete": true,
  "isKycVerified": true,
  "vehicle": { "type": "motorcycle", "plate": "ABC-123" },
  "photoUrl": "https://cdn.example.ng/photos/rider_1234.jpg",

  // Newly added requirement - existing riders get false automatically
  "isInsuranceVerified": false
}
Enter fullscreen mode Exit fullscreen mode

The Home screen checks isInsuranceVerified, renders a nudge if false, and presents the upload screen when tapped. Existing riders who have not completed it see the prompt at the top of their home screen. New riders see it as part of their natural nudge stack during onboarding. No forced logout, no breaking change, no re-onboarding flow to design.

This is also how you gate features by compliance tier. A rider without KYC can still browse the Home screen and view earnings history, but the “Go Online” button is disabled with a tooltip that says: “Verify your identity to go online.” The restriction is contextual rather than blocking. The user has already experienced the product’s value, which makes completing verification feel meaningful instead of bureaucratic.

Local State in Pattern B

In Pattern B, local state becomes more important because the UI needs to render immediately, even before the network call completes. This is what allows nudges to appear instantly on the Home screen while fresh profile data is still being fetched in the background.

To achieve this, you need a local persistence layer that can act as a fast, reactive cache. Any database can technically work, but lightweight embedded databases like Isar or ObjectBox are particularly well suited for this pattern. They provide reactive streams out of the box, making it easy for the UI to update automatically when cached onboarding state changes.

Future<void> loadProfileStatus() async {
  // Render stale data instantly - no loading spinner
  final cached = _localRepo.getProfileStatus();
  if (cached != null) {
    _status = cached;
    notifyListeners();
  }

  // Fetch fresh - correct and persist
  final result = await _fetchProfileStatusUseCase.call();
  result.fold(
    (failure) => _setError(failure),
    (fresh) {
      _status = fresh;
      _localRepo.saveProfileStatus(fresh);
      notifyListeners();
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup, the flow becomes:

  • Render cached onboarding state immediately
  • Subscribe to local database changes for reactive UI updates
  • Sync with the server in the background and overwrite stale state when fresh data arrives

This ensures the Home screen feels instant while still remaining fully consistent with the server as the source of truth.

When to Choose Pattern B

The home-first nudge approach is the right choice when:

  • Early user acquisition is the priority. The goal is to drive signups and activation, while encouraging profile completion through engagement rather than enforcing it through blocking gates.

  • The core value can be demonstrated with minimal user data. For example, a food ordering customer can browse restaurants and add items to a cart without providing a delivery address. That detail is only required at checkout.

  • You are building a platform expected to evolve over time. If compliance requirements are likely to increase, designing a nudge-based system from the start reduces significant rework later.

  • You also have early access users whose feedback is important. Even with partial profiles, you can still gather geographic distribution, engagement signals, and UX insights, all of which are valuable before the product reaches full maturity.

The Hybrid: Combining Both Patterns

In practice, the most thoughtful platforms are not purely one or the other. They use a layered approach: hard gates for the absolute minimum, nudges for everything else.

For Rider app specifically, a sensible split looks like this:

Step Pattern Reason
Phone + OTP verification Gated You need a verified identity to create any account at all
Full name + vehicle type Gated Minimum required to dispatch an order to a human rider
KYC document verification Nudged Required to go online, surfaced as a contextual prompt on Home
Profile photo upload Nudged Required for high-value order eligibility - shown after KYC
Vehicle insurance document Nudged Required for Zone B deliveries - new requirement, existing users prompted
Bank account for payouts Nudged Required to receive earnings - shown when rider first completes an order

Only enforce hard gates on data that makes it impossible for the core product to function. Everything else should be handled as a nudge, with contextual restrictions where necessary. When in doubt, prefer nudging. You can always tighten access later, but loosening a gate that has already frustrated users is much harder to recover from.

The Full Backend and Frontend Contract

Regardless of the onboarding pattern you choose, this contract is the most reliable foundation I have found across both apps. Think of it as the implementation checklist for building a truly resumable onboarding system.

Backend responsibilities

  • Every profile field must have a clearly defined state: null, empty, or populated. These meanings should be explicitly documented in the API contract and only changed through versioned migrations.

  • A single /me or /rider/status endpoint should return the full onboarding state. This endpoint is called on every app launch after login, so it must be optimized for speed and consistency.

  • All onboarding step endpoints must be idempotent. Submitting the same KYC document multiple times should not create duplicates or trigger errors.

  • The access token is issued immediately after OTP verification, not after full profile completion. This decision is what enables resumable onboarding in the first place.

The status endpoint should return granular boolean flags for each requirement rather than a single step index. A step counter only shows progress; it does not accurately represent what is actually missing.

Frontend responsibilities

  • On every app launch, after validating the token, the app must fetch the latest profile status before deciding navigation. Cached state alone should never determine routing.

  • Local cache can be used to render UI instantly, but only as a performance hint. The server response is always the source of truth and should correct any stale state.

  • A local step counter should never be used as the primary resume mechanism. It can drift out of sync when users switch devices or when backend actions update state outside the app.

  • Each onboarding screen should submit its own independent API call. There should be no single multi-step form submission. This ensures progress is persisted immediately as each step is completed.

  • Navigation within the onboarding flow must always reset the back stack. Users should not be able to navigate back from a deeper onboarding step to earlier authentication screens.

Closing Thoughts

The rider app is more difficult than the customer app, not because it has more screens, but because the cost of a poor onboarding experience is much higher. A customer who abandons signup can usually return without friction. A rider who drops off mid-KYC may never come back, and if they do, losing their progress is often enough reason to switch to another platform.

Building resumable, server-driven onboarding from day one is not overengineering. It is the baseline architecture for any serious multi-step flow.

Whether you choose to gate steps or use nudges is a product decision. It depends on your growth strategy, compliance requirements, and how much value your product can deliver before full verification. But the underlying infrastructure remains the same: idempotent APIs, well-defined state semantics, local caching as a performance hint rather than a source of truth, and a single resolver that translates profile state into navigation.

Design the backend contract with care. Build the resolver once. Everything else is just screens.

Top comments (0)