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)

. 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