User Onboarding Flow System Low-Level Design

What is an Onboarding Flow System?

An onboarding flow guides new users through setup steps to reach the “aha moment” — the point where they understand the product’s value. Good onboarding increases activation (users who complete setup), retention (users who return), and reduces churn. Slack’s onboarding (create workspace → invite team → send first message), Stripe’s (add business details → connect bank → process test payment), and Notion’s (choose template → invite collaborator → create first doc) are well-studied examples. The technical system tracks step completion, personalizes the flow, and measures funnel drop-off.

Requirements

  • Define multi-step onboarding flows with conditional branches (show different steps based on user attributes)
  • Track per-user step completion status
  • Resume from where the user left off (across sessions, devices)
  • A/B test different onboarding flows
  • Analytics: measure completion rate per step, time to completion, drop-off points
  • Show progress (step 2 of 5) and completion percentage

Data Model

OnboardingFlow(
    flow_id     UUID PRIMARY KEY,
    name        VARCHAR,        -- 'standard', 'enterprise', 'developer'
    version     INT,
    status      ENUM(ACTIVE, DEPRECATED),
    steps       JSONB           -- ordered step definitions (see below)
)

-- Flow step definition (stored in JSONB):
{
  "steps": [
    {"step_id": "profile", "title": "Complete your profile",
     "required": true, "condition": null},
    {"step_id": "invite_team", "title": "Invite your team",
     "required": false, "condition": {"plan": "team"}},
    {"step_id": "connect_integration", "title": "Connect your first integration",
     "required": true, "condition": null}
  ]
}

UserOnboarding(
    user_id     UUID NOT NULL,
    flow_id     UUID NOT NULL,
    status      ENUM(IN_PROGRESS, COMPLETED, SKIPPED),
    started_at  TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, flow_id)
)

UserOnboardingStep(
    user_id     UUID NOT NULL,
    flow_id     UUID NOT NULL,
    step_id     VARCHAR NOT NULL,
    status      ENUM(PENDING, COMPLETED, SKIPPED),
    completed_at TIMESTAMPTZ,
    metadata    JSONB,   -- e.g., {'invited_count': 3}
    PRIMARY KEY (user_id, flow_id, step_id)
)

Step Completion Logic

def complete_step(user_id, step_id, metadata=None):
    # Mark step complete
    db.upsert(UserOnboardingStep(
        user_id=user_id, flow_id=get_user_flow(user_id),
        step_id=step_id, status='COMPLETED',
        completed_at=now(), metadata=metadata
    ))

    # Check if all required steps are now complete
    flow = get_user_flow_definition(user_id)
    required_steps = [s['step_id'] for s in flow.steps if s['required']
                      and evaluate_condition(s['condition'], user_attributes)]
    completed = db.get_completed_steps(user_id)

    if set(required_steps).issubset(set(completed)):
        db.update(UserOnboarding, user_id=user_id, status='COMPLETED',
                  completed_at=now())
        emit_event('onboarding.completed', user_id=user_id)

def get_next_step(user_id):
    flow = get_user_flow_definition(user_id)
    completed = set(db.get_completed_steps(user_id))
    for step in flow.steps:
        if step['step_id'] not in completed:
            if evaluate_condition(step.get('condition'), get_user_attrs(user_id)):
                return step
    return None  # all steps complete

Step Triggers (Event-Driven Completion)

Some steps complete automatically when the user performs the associated action, not when they click a button:

# "Connect integration" step completes when user creates their first integration
# Integration service emits event → onboarding service listens

@kafka_consumer('integration.created')
def on_integration_created(event):
    user_id = event['user_id']
    if is_step_pending(user_id, 'connect_integration'):
        complete_step(user_id, 'connect_integration',
                      metadata={'integration_id': event['integration_id']})

Event-driven completion is more reliable than requiring explicit “mark complete” calls — the step completes regardless of how the user navigated to the action.

Analytics: Funnel Drop-off

-- Completion rate per step
SELECT
    step_id,
    COUNT(*) FILTER (WHERE status='COMPLETED') AS completed,
    COUNT(*) AS total_reached,
    ROUND(100.0 * COUNT(*) FILTER (WHERE status='COMPLETED') / COUNT(*), 1) AS completion_rate
FROM UserOnboardingStep
WHERE flow_id = :flow_id
GROUP BY step_id
ORDER BY step_id;

-- Median time to complete onboarding
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY
    EXTRACT(EPOCH FROM completed_at - started_at)/3600
) AS median_hours
FROM UserOnboarding WHERE status = 'COMPLETED';

Key Design Decisions

  • Flow definition in JSONB — allows adding/modifying steps without schema migrations; versioned for safe changes
  • Event-driven step completion — reliable; works even when users navigate directly to a feature bypassing the onboarding UI
  • Conditional steps — enterprise users see team-invite steps; solo users don’t; same flow definition handles both
  • Required vs optional steps — required steps block “completion”; optional steps can be skipped without penalty
  • A/B test different flows — assign users to flow_id at signup using experiment assignment (hash-based, same as A/B testing system)

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you design an onboarding flow that works across multiple sessions and devices?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store onboarding state server-side in a UserOnboardingStep table, not in client-side state. The API returns the current step status on every page load — the UI renders based on server state, not local storage. This makes the flow resumable on any device: user starts onboarding on mobile, finishes on desktop. The "next step" calculation happens server-side using get_next_step(user_id), scanning steps in order and returning the first incomplete required step.”}},{“@type”:”Question”,”name”:”How do you handle onboarding steps that complete automatically (event-driven)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use event-driven completion: subscribe to domain events from other services via Kafka. When a user creates their first integration (integration.created event), the onboarding service receives the event and marks the "connect_integration" step complete. This is more reliable than requiring explicit "mark complete" button clicks — it works even when users navigate to the feature directly, bypassing the onboarding UI. The onboarding service becomes a subscriber, not a gatekeeper.”}},{“@type”:”Question”,”name”:”How do you A/B test different onboarding flows?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Assign users to a flow_id at signup using hash-based experiment assignment: flow = FLOWS[hash(user_id + experiment_id) % len(FLOWS)]. Store the assigned flow_id in UserOnboarding. All subsequent API calls use this assigned flow — the user sees consistent steps across sessions. Track completion rates per flow_id to identify which flow drives higher activation. Use statistical significance testing (chi-squared for conversion rates) before declaring a winner.”}},{“@type”:”Question”,”name”:”How do you measure onboarding funnel drop-off?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Query UserOnboardingStep grouped by step_id: count total users who reached each step and count completions. The "reached" count for step N is the completion count for step N-1. Compute completion_rate = completed / reached per step. Steps with <50% completion are friction points. Also compute p50/p90 time between steps — long gaps indicate confusion or lost interest. Export to a BI tool (Metabase, Looker) for visualization.”}},{“@type”:”Question”,”name”:”How do you handle onboarding for different user types (solo vs team)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use conditional steps: each step definition includes a condition that evaluates user attributes. A team-invite step might have condition: {"plan": ["team", "enterprise"]}. At render time, evaluate conditions against the user’s current attributes (plan, role, industry). Skip steps whose conditions don’t match. Store conditions in the flow JSONB so they can be updated without code deploys. This allows a single flow to handle multiple user types with personalized step sequences.”}}]}

User onboarding and activation flow design is discussed in Stripe system design interview questions.

Onboarding flow and user activation system design is in Atlassian system design interview preparation.

Merchant onboarding and guided setup flow design is covered in Shopify system design interview guide.

Scroll to Top