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.