Conversion Funnel Optimization Questions
Analyzing and improving a bounded, ordered conversion path: mapping the sequence of steps a user takes from acquisition through one terminal conversion or activation event (signup, first purchase, first paid order, trial-to-paid, onboarding to first-success), computing step-to-step and overall conversion rates and drop-off, and diagnosing where and why users fall out. Covers the SQL and query techniques for computing funnel metrics at scale (stage-by-stage conversion tables, time-to-conversion and time-to-first-value, cohort LTV measured within a funnel window, path analysis across non-linear user journeys, event instrumentation and data-quality practices for funnel tracking), attribution modeling for crediting conversions across channels and touchpoints (first-touch, last-touch, linear, time-decay, Markov-chain, and Shapley-value approaches) and customer acquisition cost by channel, and the experiment design and statistics used to validate funnel changes (A/B and multi-armed-bandit test design, sample-size and power calculations, quasi-experimental methods such as difference-in-differences and synthetic control when randomization is not possible, and testing whether a single funnel-stage drop is a real, statistically significant shift rather than noise). Also covers diagnosing UX and flow friction that causes drop-off (checkout, signup, and onboarding friction points) and prioritizing a program of funnel-improvement experiments (impact and effort frameworks such as RICE or ICE, guardrail metrics, roadmap sequencing). Distinct from User Retention and Engagement, which covers what an already-converted or already-activated user does afterward: repeat usage over time, cohort retention curves, DAU/WAU/MAU, churn, and reactivation. A question belongs here if it concerns a user's first, bounded pass toward one conversion or activation event; it belongs to User Retention and Engagement if it concerns recurring behavior after that event. General-purpose rolling-window anomaly and change-point detection techniques (CUSUM, Bayesian change-point, seasonality-aware baselines) for monitoring any metric over time belong to the companion topic Advanced SQL: Metric Monitoring, Anomaly Detection, and Data Correctness at Scale, not here.
Write an ANSI SQL query that computes a stage-by-stage funnel conversion table for these ordered events: 'view_product', 'add_to_cart', 'begin_checkout', 'purchase', 'subscribe'. Input table: events(user_id BIGINT, event_name VARCHAR, event_timestamp TIMESTAMP). The output should show unique users at each stage and the conversion rate between consecutive stages for users whose first event occurred in the last 30 days. Explain your deduplication logic in a comment.
Sample Answer
Direct answer
Build the funnel table with a CTE chain: first find each user's very first event timestamp and filter to users whose first event is within the last 30 days, then count distinct users per stage and divide each stage's count by the prior stage's count for the conversion rate. Deduplication comes from COUNT(DISTINCT user_id), which collapses any repeated firing of the same event (a user viewing the same product twice still counts once at the view_product stage) without needing a separate pre-pass to remove duplicate rows.
Structured elaboration
Approach. Three steps: (1) compute each user's first-ever event timestamp and keep only users whose first event falls in the last 30 days, this is the cohort filter, anchored to the user's OWN first activity, not to any individual stage's timestamp; (2) join the five named stages against that cohort; (3) aggregate with COUNT(DISTINCT user_id) per stage and compute each stage's conversion rate relative to the stage immediately before it in the declared order (view_product to add_to_cart to begin_checkout to purchase to subscribe).
Deduplication logic, explained. A user can fire the same event multiple times (view a product repeatedly, or, less obviously, purchase twice in the observation window). COUNT(DISTINCT user_id) inside each stage's aggregation handles this correctly on its own: it does not matter how many times a user appears in the filtered row set for a given stage, they are counted exactly once. This is simpler and less error-prone than pre-deduplicating the events table itself, since a pre-dedup step (say, keeping only the earliest row per user/event pair) adds complexity for no benefit when the aggregation step already collapses duplicates.
Worked example
Schema: events(user_id BIGINT, event_name VARCHAR, event_timestamp TIMESTAMP). Synthetic data (6 users) deliberately includes: a duplicate view_product event (user 2), a duplicate purchase event (user 6), and a user whose first-ever event is 45 days ago even though several of their individual events fall inside the last 30 days (user 4, who must be EXCLUDED entirely by the cohort filter).
CREATE TABLE events (user_id INTEGER, event_name TEXT, event_timestamp TIMESTAMP);
INSERT INTO events VALUES
(1,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 20 DAY)),
(1,'add_to_cart', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 19 DAY)),
(1,'begin_checkout', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 18 DAY)),
(1,'purchase', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 17 DAY)),
(1,'subscribe', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 16 DAY)),
(2,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 20 DAY)),
(2,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 19 DAY)), -- duplicate view_product
(2,'add_to_cart', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 19 DAY)),
(2,'begin_checkout', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 18 DAY)),
(2,'purchase', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 17 DAY)),
(2,'subscribe', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 16 DAY)),
(3,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 15 DAY)), -- reaches view_product only
(4,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 45 DAY)), -- first event 45 days ago
(4,'add_to_cart', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 20 DAY)), -- but this later event IS within 30 days
(4,'begin_checkout', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 10 DAY)), -- so is this one; user 4 still must be excluded
(5,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 10 DAY)),
(5,'add_to_cart', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 9 DAY)), -- reaches add_to_cart only
(6,'view_product', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 8 DAY)),
(6,'add_to_cart', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 7 DAY)),
(6,'begin_checkout', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 6 DAY)),
(6,'purchase', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 DAY)),
(6,'purchase', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 DAY)); -- duplicate purchase
WITH first_event AS (
-- first_event_ts anchors the 30-day cohort window to when the user FIRST
-- appeared at all, not to any individual stage's timestamp.
SELECT user_id, MIN(event_timestamp) AS first_event_ts
FROM events
GROUP BY user_id
),
cohort AS (
-- Only users whose very first event happened in the last 30 days are
-- included. A user whose first event is 45 days ago is excluded even if
-- three of their later events individually fall inside the 30-day window.
SELECT user_id
FROM first_event
WHERE first_event_ts >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 DAY)
),
stage_users AS (
SELECT e.user_id, e.event_name
FROM events e
JOIN cohort c ON c.user_id = e.user_id
WHERE e.event_name IN ('view_product','add_to_cart','begin_checkout','purchase','subscribe')
),
stage_counts AS (
-- COUNT(DISTINCT user_id) is the deduplication: a user who fires the same
-- stage event twice (user 2's duplicate view_product, user 6's duplicate
-- purchase) is still counted exactly once here.
SELECT event_name, COUNT(DISTINCT user_id) AS users_at_stage
FROM stage_users
GROUP BY event_name
),
stage_order AS (
SELECT 'view_product' AS event_name, 1 AS ord UNION ALL
SELECT 'add_to_cart', 2 UNION ALL
SELECT 'begin_checkout', 3 UNION ALL
SELECT 'purchase', 4 UNION ALL
SELECT 'subscribe', 5
)
SELECT
sc.event_name,
sc.users_at_stage,
ROUND(
1.0 * sc.users_at_stage /
NULLIF(LAG(sc.users_at_stage) OVER (ORDER BY so.ord), 0),
4
) AS conversion_rate_from_prior_stage
FROM stage_counts sc
JOIN stage_order so ON so.event_name = sc.event_name
ORDER BY so.ord;
Output (actually executed against SQLite, standing in for the ANSI/BigQuery date functions above; the WITH ... GROUP BY / window-function logic is identical, only the date-subtraction syntax differs between engines):
Stage-by-stage funnel table:
stage users conv_from_prior
view_product 5 -
add_to_cart 4 0.8
begin_checkout 3 0.75
purchase 3 1.0
subscribe 2 0.6667
The first stage's conversion rate is blank by design: LAG() has nothing before view_product to divide by, which correctly represents that "conversion into the funnel's own entry point" is not a meaningful ratio. User 4 (first event 45 days ago) correctly does not appear anywhere in this table: 5 users reached view_product, not 6, confirming the cohort filter excluded them despite their later events falling inside the 30-day window.
Free-to-paid FIRST-transition variant, a genuinely different pattern. "First occurrence of a named stage" (above) is not the same problem as "first TRANSITION into a recurring state": a user's plan status can flip free to paid to free to paid repeatedly (downgrade, then re-upgrade). A naive filter on plan_type = 'paid' alone cannot distinguish "the first time they went paid" from "a later re-upgrade"; the query needs to also check that the immediately preceding row was NOT paid. Executed against a second synthetic table:
CREATE TABLE subscription_events (user_id INTEGER, plan_type TEXT, changed_at TIMESTAMP);
INSERT INTO subscription_events VALUES
(1,'free','2026-06-10 12:00:00'),
(1,'paid','2026-07-05 12:00:00'), -- user 1's TRUE first paid transition
(1,'free','2026-07-20 12:00:00'), -- downgrades back to free
(1,'paid','2026-07-25 12:00:00'), -- re-upgrades; must NOT be reported as the first transition
(2,'paid','2026-07-12 12:00:00'); -- user 2: created directly on a paid plan, no prior free row
-- 'First transition to paid' needs the row where plan_type = 'paid' AND the
-- immediately preceding row (by timestamp) was NOT 'paid' (or there is no
-- preceding row). Otherwise a later re-upgrade timestamp could be confused
-- with the true first conversion if the query only checked plan_type='paid'
-- without looking at what came immediately before.
WITH ordered AS (
SELECT
user_id, plan_type, changed_at,
LAG(plan_type) OVER (PARTITION BY user_id ORDER BY changed_at) AS prev_plan
FROM subscription_events
),
transitions_to_paid AS (
SELECT user_id, changed_at
FROM ordered
WHERE plan_type = 'paid' AND (prev_plan IS NULL OR prev_plan != 'paid')
)
SELECT user_id, MIN(changed_at) AS first_paid_transition
FROM transitions_to_paid
GROUP BY user_id
ORDER BY user_id;
Output (actually executed): a user who went free (day 30 ago) then paid (day 25 ago) then free again (day 10 ago) then paid again (day 5 ago) correctly resolves to first_paid_transition = day 25 ago, the TRUE first conversion, not the more recent re-upgrade.
First free->paid transition per user (ignoring later re-upgrades):
(1, '2026-07-05 12:00:00')
(2, '2026-07-12 12:00:00')
Complexity
The stage-by-stage query is dominated by two full scans of events: one for first_event (GROUP BY user_id, O(n) in the number of event rows) and one for stage_users/stage_counts (another O(n) pass, filtered and grouped). The LAG() window function over the final 5-row stage_counts result is O(1) in practice, since it operates only on the already-aggregated per-stage totals, not the raw event rows. Overall: O(n) time in the number of raw events, O(u) space for the per-user first-event map where u is the distinct user count. The free-to-paid transition query adds one LAG() OVER (PARTITION BY user_id ORDER BY changed_at) pass, also O(mlogm) per user for the implied per-partition sort (or O(m) if the source data is already sorted by time), where m is that user's row count in subscription_events.
Edge cases
- A user with zero events at all never appears in
first_eventand is therefore correctly absent from every stage count, rather than appearing with a misleading zero. - A user whose ONLY events are outside the 5 named stages (some unrelated event type) is correctly excluded from
stage_users, since theWHERE event_name IN (...)filter runs before any counting happens. - Ties at the exact 30-day boundary:
first_event_ts >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 DAY)includes a user whose first event is exactly 30 days old, an inclusive boundary; confirm this matches the intended semantics before shipping, since an off-by-one here silently shifts the cohort. - For the free-to-paid query, a user with only ONE
paidrow and no priorfreerow (a user created directly on a paid plan, never trialed) is handled correctly:prev_plan IS NULLon their first row satisfies the transition condition, so their first paid timestamp is still captured.
At scale: partition pruning, clustering, and cost
Once the basic query works on real event volumes (tens of millions to billions of rows), three levers matter, roughly in the order to reach for them:
- Partition pruning. Partition
eventsbyevent_timestamp(typically daily). Thefirst_eventCTE scans the full table for each user's minimum timestamp; add a coarse pre-filter (event_timestamp >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 90 DAY), wide enough to certainly contain every user's first event) so the engine opens only the partitions that can matter. - Clustering on
user_id. Most funnel queries filter and group byuser_idrepeatedly. Clustering the table onuser_id(BigQuery clustering, Redshift SORTKEY/DISTKEY) lets the engine skip blocks that cannot contain a given user's rows. - Approximate counts and materialized views for dashboards. For a frequently-refreshed dashboard,
APPROX_COUNT_DISTINCT(HyperLogLog-based, sub-1% typical error) on the stage counts, and a materialized view that pre-aggregates stage counts on a schedule, both trade a small amount of exactness for real cost savings; the materialized view is usually the higher-leverage fix, since it turns a query re-run per dashboard view into one that runs once per refresh interval.
Trade-offs and pitfalls
- Common mistake: filtering
event_timestamp >= last 30 dayson individual event rows instead of anchoring on the user's FIRST event. That silently changes the cohort definition (it would include users whose stage-4 event happens to be recent even though they signed up a year ago), which is exactly what user 4 in the worked example is designed to catch. - Common mistake: assuming the stage order in the output matches the stage order in the funnel just because the events are grouped; always join against an explicit ordering table or
CASEexpression rather than relying on alphabetical or insertion order, which is not guaranteed by SQL semantics. - The at-scale techniques above are not a checklist to apply universally; approximate counts trade a small, usually acceptable error for real cost savings only once table scans are actually the bottleneck. Reaching for
APPROX_COUNT_DISTINCTon a table that already fits comfortably in a partition-pruned, clustered scan is unnecessary complexity with no measurable benefit.
Design an A/B test to reduce checkout abandonment. Define the hypothesis, primary and guardrail metrics, sample size estimation approach, measurement window, segmentation rules, and rollout plan. Explain how you'd handle users who appear in multiple concurrent experiments and how you'd prevent cross-contamination.
Sample Answer
Direct answer
Design the checkout-abandonment test around a single primary metric (checkout completion rate) with named guardrails (refund rate, payment failure rate, revenue per visitor), a sample size sized off the primary metric's baseline rate and minimum detectable effect, a measurement window long enough to cover a full weekly cycle plus any post-purchase guardrail lag, deterministic-hash bucketing so users are consistently assigned and isolated from other concurrent experiments, and a staged rollout (5% then 50% then 100%) gated on both the primary metric and every guardrail staying healthy at each stage.
Structured elaboration
Getting to the hypothesis before designing the test. A test does not start from "let's A/B test the checkout button"; it starts from a diagnosed problem: pull the funnel drop-off data for the checkout step, segment it (device, payment method, new versus returning), and find where drop-off concentrates. If mobile users abandon at 2x the desktop rate specifically at the payment-form step, the hypothesis becomes concrete and falsifiable, not "make checkout better" but a specific, testable causal claim, shown next.
Hypothesis. "Reducing the mobile checkout payment form from 3 required fields to 1 (with autofill) increases mobile checkout completion rate, because the current form's field count is the primary abandonment driver for mobile users at that step."
Primary and guardrail metrics. Primary: checkout completion rate (users who reach checkout_start and reach purchase, the metric the hypothesis is directly about). Guardrails, metrics that must NOT get worse even if the primary improves: refund/chargeback rate within 7 days of purchase, payment failure/decline rate, average order value, and page load time on the checkout step. Guardrails exist because a change can win on the primary metric while quietly damaging something else the primary metric cannot see; the concrete case worth naming explicitly: a simplified, lower-friction checkout can raise completion rate while ALSO raising refund and fraud rates, because some of the friction being removed (a required billing-address confirmation, for example) was doing real fraud-prevention or informed-consent work, not just slowing people down. A test that only looks at the primary metric would ship a change that looks like a clear win and is actually trading real revenue for a fraud or return-rate problem that shows up weeks later. Refund rate needs its own, LONGER measurement window than the primary metric for exactly this reason (see Measurement window below).
Sample size, approach (not full derivation). Sample size for a proportions test like this depends on four inputs: the baseline conversion rate (checkout completion today), the minimum detectable effect (the smallest lift worth being able to detect, decided by business judgment, not statistics, typically informed by the revenue value of that lift versus the cost of running the test longer), the desired significance level (commonly α=0.05), and the desired statistical power (commonly 1−β=0.80). Those four feed a standard two-proportion power calculation to produce the required sample size per arm; this answer names the inputs and the approach at a conceptual level, since the point here is knowing which four factors determine sample size and how they trade off against each other, not re-deriving the closed-form formula step by step. Two practical notes beyond the inputs: (1) the baseline rate should come from RECENT data for the exact segment being tested (mobile checkout specifically, not the blended overall rate), since a mismatched baseline silently under- or over-powers the test; (2) if traffic is limited, a smaller minimum detectable effect requires disproportionately more sample, so the MDE choice is often the practical lever that determines whether the test is even feasible in a reasonable timeframe.
Measurement window. Primary metric: at least one full week (to average out day-of-week effects like weekday-versus-weekend purchase behavior), and typically extended to cover 2 full weekly cycles for a stable read. Guardrails on a LAG, like refund rate, need a window that extends PAST the point where the last user entered the test, since a refund can happen up to the refund policy's window (7, 14, or 30 days) after purchase; declaring the test a winner from primary-metric data alone, before the guardrail's own lag window has closed, is how the conversion-versus-refunds tension above gets missed in practice.
Segmentation rules. Pre-register the segments you will check (mobile versus desktop, new versus returning, geography) BEFORE launching, not after seeing results; this is a bias-prevention rule, since hunting for a significant segment after the fact (the primary metric was flat, but "mobile users in Region X" moved) inflates the false-positive rate through unplanned multiple comparisons. If mobile is the hypothesized driver, mobile can be the PRIMARY analysis population rather than just a post-hoc segment, with desktop as a secondary check that the change did not regress the segment it was not designed for.
Rollout plan. Staged: 5% of eligible traffic for an initial health check (catch a broken build or an obviously bad guardrail signal fast, cheaply), then 50% once nothing looks wrong, then 100% once the test reaches its planned sample size and duration and the primary metric plus every guardrail clears their bar. Each stage gate is a genuine decision point, not a formality: a guardrail regression at 5% should halt the rollout before it reaches more users, not just get noted for later.
Worked example: novelty effects and personalization-decay
A test of a PERSONALIZED checkout flow (recommended payment method pre-selected based on past behavior) surfaces a pitfall generic to any personalization change, worth calling out explicitly rather than folding into the generic guardrail list above: a novelty effect can make week-1 results look better than the change's true steady-state impact, because some of the lift is users reacting to something new and different rather than to the personalization genuinely being more relevant. The concrete failure mode: a personalization model trained on stale behavioral data can also DECAY in relevance over time as user preferences drift, so a test that looks flat-to-positive in week 1 and week 2 can quietly erode by week 6 as the underlying model's recommendations grow less accurate relative to current behavior, an effect a short measurement window would never catch. The mitigation is to extend monitoring PAST the initial test window into a post-launch tracking period specifically watching for a downward trend in the personalized arm's lift over time, not just a single end-of-test readout, and to re-train or re-validate the personalization model on a defined cadence so a stale model is caught before its degraded relevance shows up as a silent conversion-rate regression.
Trade-offs and pitfalls: running many concurrent experiments (the experiment-platform question)
This question's own ask, how to handle users in multiple concurrent experiments and prevent cross-contamination, is a real system-design problem once a company runs more than a handful of tests at once, and deserves concrete technical treatment, not just a mention:
- Deterministic hash-based bucketing. Assign each user to a variant via a hash of
(user_id, experiment_id)(for examplehash(user_id + experiment_id) mod 100), NOT a random draw at request time. This gives two properties that matter: the SAME user always lands in the SAME variant across sessions and devices as long asuser_idis stable (consistency), and, critically, hashinguser_idcombined with the experiment's OWN id means a user's bucket in experiment A is statistically INDEPENDENT of their bucket in experiment B, because the two hashes are effectively unrelated draws even for the same user. This independence is what allows many experiments to run simultaneously on overlapping populations without the assignments correlating with each other. - Overlapping-audience management via layers/namespaces. A common pattern (used by large-scale experimentation platforms) organizes experiments into orthogonal LAYERS: within one layer, experiments are mutually exclusive (a user in the checkout-form-layout experiment cannot simultaneously be in a different checkout-form-layout experiment), while experiments in DIFFERENT layers (checkout layout versus a pricing-page experiment) can run concurrently on the same user, since they touch unrelated parts of the product and their effects are assumed roughly additive/independent. This lets a platform run dozens of tests at once without every pair needing manual conflict analysis.
- Explicit interaction checks for experiments that plausibly interact. Orthogonal hashing prevents ACCIDENTAL correlation, but it does not prevent two experiments from genuinely interacting in effect (a checkout-form simplification test and a pricing-display test both touching the same screen could interact even with independent bucketing). For experiments the team suspects might interact, either force them into the same layer (mutual exclusion) or run an explicit 2x2 factorial analysis on the overlap population to check whether the combined effect differs from the sum of the individual effects.
- Cross-contamination beyond assignment: leakage through shared infrastructure. Bucketing correctly is necessary but not sufficient; a shared cache, a shared recommendation model retrained on mixed-variant data, or a support agent manually "fixing" a treatment-group user's cart back to the control experience are all ways cross-contamination can happen AFTER correct assignment. Auditing for this means checking that variant-specific behavior does not leak into shared state that both arms read from.
Other pitfalls: running the test on a stage-X restatement (payment-entry drop-off, or onboarding/activation instead of checkout) uses this exact same framework unchanged, only the hypothesis, primary metric, and guardrails swap to match the new stage, nothing about the sample-size approach, bucketing, or rollout staging logic changes. Common mistake: treating the 5% rollout stage as a formality rather than a real gate, shipping straight to 50% because the initial numbers "look fine" after only a few hours, well before the guardrails (especially the lagged refund-rate guardrail) have had time to produce a meaningful read.
You have a simple 3-step funnel with monthly unique user counts: Step A (landing page) = 100,000, Step B (signup) = 8,000, Step C (paid conversion) = 1,200. Calculate: overall conversion rate A→C, step-to-step conversion rates A→B and B→C, and percentage drop-off at each step. Show formulas and final numbers.
Sample Answer
Direct answer
With Step A (landing) = 100,000, Step B (signup) = 8,000, and Step C (paid) = 1,200: overall conversion A to C is 1.2%, step A to B is 8%, step B to C is 15%, drop-off at A to B is 92%, and drop-off at B to C is 85%. The A to B step is the priority: it has both the lower conversion rate and, by a wide margin, the larger absolute number of users lost.
Structured elaboration
Formulas. For any two steps, conversion rate is users at the later step divided by users at the earlier step, and drop-off is one minus that rate (equivalently, users lost divided by users at the earlier step):
conversion rate=users at earlier stepusers at later step,drop-off=1−conversion rateOverall conversion across a multi-step funnel is either computed directly (final step users divided by first step users) or as the product of the individual step rates, and both must agree:
overall=AC=(AB)(BC)Applying the formulas:
- Overall A to C: 1,200/100,000=1.2%
- Step A to B: 8,000/100,000=8%
- Step B to C: 1,200/8,000=15%
- Drop-off A to B: 1−8%=92%, meaning 92,000 of the 100,000 landing-page visitors never signed up.
- Drop-off B to C: 1−15%=85%, meaning 6,800 of the 8,000 signups never paid.
- Check: 0.08×0.15=0.012=1.2%, matching the direct overall calculation.
Which step to prioritize. Two lenses matter and they can disagree, so check both: the RELATIVE rate (which step converts a smaller share of the people who reach it) and the ABSOLUTE volume lost (which step, in raw user count, is bleeding the most people, since a small percentage improvement on a huge base can outweigh a large percentage improvement on a small base). Here they agree: A to B converts at only 8% (versus B to C's 15%), and A to B also loses 92,000 users in absolute terms versus B to C's 6,800, roughly 13.5 times more. Both signals point the same direction, so A to B (landing to signup) is the clear priority. When the two lenses disagree, in general prioritize by the absolute number of users recoverable at a plausible improvement size, since that is what actually shows up in the paid-conversion count at the bottom of the funnel, not the percentage alone.
Worked example
Uplift-projection variant: what if a step's conversion rate improved? Take a separate, illustrative scenario built on the same funnel shape and the same 100,000-visitor base: suppose the A to B step conversion rate improved from a baseline of 20% to 30% (an illustrative before/after pair, distinct from this funnel's actual 8% observed rate, used here to demonstrate how an improvement propagates downstream). Holding the actual B to C rate fixed at 15%:
- Before: 100,000×20%=20,000 reach step B; downstream paid conversions: 20,000×15%=3,000.
- After: 100,000×30%=30,000 reach step B; downstream paid conversions: 30,000×15%=4,500.
- Incremental paid conversions: 4,500−3,000=1,500, a 50% relative increase in paid conversions from a 10-percentage-point improvement at the first step.
This illustrates the general propagation rule: an improvement at an EARLY step multiplies through every downstream rate, so the revenue impact of fixing the A to B step is proportionally larger than the same percentage-point improvement made at B to C, purely because more of the funnel's total user base passes through the earlier step.
A near-identical restatement worth naming, not reworking. A version of this question that swaps the funnel to a Discover, Activate, Retain framing with the same "which step would you prioritize" ask, or one that relabels the raw counts as page views instead of landing-page visits, is testing the exact same skill: compute each rate, compute drop-off, compare relative and absolute loss, and justify a prioritization call. Nothing in the underlying reasoning changes when only the stage labels or the cover story changes.
Trade-offs and pitfalls
- Percentage points versus relative percentage. Saying a rate "improved by 10%" is ambiguous: going from 20% to 30% is a 10-PERCENTAGE-POINT increase but a 50% RELATIVE increase. Always state which one you mean; conflating the two is a common way to overstate or understate an improvement's real size.
- A single point-in-time snapshot hides trend. These three numbers are a snapshot; without knowing whether 8% at A to B is stable, improving, or degrading over time, prioritizing purely off one month's numbers risks reacting to noise rather than a real pattern.
- Absolute volume can mislead when a step's population is not truly comparable. If Step A includes a large amount of bot or low-intent traffic that Step B naturally filters out, the 8% rate may partly reflect traffic quality rather than a fixable UX problem; a prioritization decision made purely on the arithmetic above should be sanity-checked against what is actually driving the low rate before committing resourcing to "fix" it.
- Common mistake: treating drop-off percentages as if they were independent of each other, and assuming fixing the worse-percentage step alone doubles overall conversion. Because overall conversion is the PRODUCT of step rates, an improvement at one step still gets multiplied by the (unchanged) rate of every other step, so the overall lift is smaller than the step-level percentage-point gain might suggest, exactly as the uplift-projection example above shows (a 10-point step improvement yielded a 50% relative lift in paid conversions, not a 10-point lift in the overall rate).
You're mapping and instrumenting the signup-to-purchase user journey for a mobile consumer app. For each funnel step (e.g., 'app_open', 'product_view', 'add_to_cart', 'checkout_start', 'purchase') list the event names, required event properties (including data types), which events belong client-side vs server-side, and how you'd ensure idempotency and ordering. Also describe a simple versioning strategy for the event schema to support analytics over time.
Sample Answer
Direct answer
Instrument each step (app_open, product_view, add_to_cart, checkout_start, purchase) as its own named event carrying a typed property schema, fire user-initiated events client-side and money-moving or security-sensitive events server-side (ideally both, with the server-side event as the source of truth), guarantee idempotency with a client-generated event ID the backend deduplicates on, guarantee ordering with a monotonic client-side sequence number rather than trusting wall-clock arrival order, and version the schema explicitly so historical events remain interpretable as the tracking plan evolves.
Structured elaboration
Event names and required properties, with data types. A consistent naming convention (snake_case, object_action ordering) makes the tracking plan self-documenting:
| Event | Event name | Required properties (name: type) |
|---|---|---|
| App opened | app_open | session_id: string, app_version: string, platform: enum('ios','android'), is_cold_start: boolean |
| Product viewed | product_view | product_id: string, category: string, price_cents: integer, currency: string (ISO 4217, the standard 3-letter currency code), referrer_screen: string |
| Added to cart | add_to_cart | product_id: string, quantity: integer, unit_price_cents: integer, cart_id: string |
| Checkout started | checkout_start | cart_id: string, item_count: integer, cart_value_cents: integer, currency: string |
| Purchase completed | purchase | order_id: string, cart_id: string, total_cents: integer, currency: string, payment_method: enum, tax_cents: integer |
Every event, regardless of stage, also carries a common envelope: event_id: string (UUID, a universally unique identifier), user_id: string (nullable pre-login), client_event_ts: timestamp, schema_version: integer. price_cents and total_cents are integers (minor currency units, cents) rather than floating-point dollars, a deliberate choice: floating-point representations of money accumulate rounding error across aggregation and are a well-known source of off-by-a-cent bugs in analytics pipelines.
Client-side versus server-side split. The rule of thumb: fire an event client-side when it represents pure UI interaction the server has no independent way to observe (app_open, product_view, add_to_cart are all things a user does inside the app that the backend only learns about if the client tells it). Fire an event server-side, or duplicate it server-side, when it represents something the backend can verify independently and where correctness matters for revenue or fraud (purchase above all: the backend that actually processes payment and creates the order record is the authoritative source, since a client-only purchase event can be spoofed, dropped by a crash before it sends, or double-fired by a flaky retry). checkout_start is a reasonable middle case: client-fired for responsiveness, but if the backend also creates a "checkout session" record when the checkout API is called, that server record becomes a valuable cross-check against the client event, useful for detecting instrumentation bugs (a mismatch between client-reported and server-reported checkout starts is itself a signal something is broken).
Idempotency. Mobile clients retry on flaky networks, and a user can also double-tap a button before the UI disables it; both can fire the same logical event twice. The fix is a client-generated event_id (a UUID created at the moment the event is captured, not at send time, so a retried send reuses the same ID) that the ingestion backend treats as a deduplication key, typically via an upsert or a short-lived dedup cache keyed on event_id. Server-side events get the same treatment using a server-generated idempotency key tied to the business operation (for purchase, the order_id itself is a natural idempotency key, since the payment provider's own idempotency guarantees usually already prevent a duplicate charge from a retried request).
Ordering. Client-side wall-clock timestamps are not reliable for ordering: device clocks drift, users travel across timezones, and events queued while offline can be sent in a batch after connectivity returns, arriving at the server in a different order than they occurred. Attach a monotonically increasing client-side sequence number per session (a simple incrementing counter, reset at app_open) alongside the timestamp; downstream processing sorts by (session_id, sequence_number) for anything ordering-sensitive, and treats client_event_ts as informational (useful for latency analysis) rather than authoritative for sequencing.
Schema versioning strategy. Three practical pieces: (1) every event payload carries an explicit schema_version: integer field, incremented whenever a property is added, removed, or its type or meaning changes; (2) the ingestion pipeline keeps a versioned schema registry so a downstream consumer can look up "what did schema_version: 3 of purchase mean" rather than guessing from the data; (3) additive changes (a new optional property) do not require a version bump if consumers are built to tolerate unknown fields, but changing an existing property's TYPE or MEANING (renaming price to price_cents and changing units) always requires a version bump, since silently reinterpreting old data under a new meaning corrupts historical analysis.
Worked example
Single-event deep dive: checkout_complete, naming conventions and privacy. Take one event and work through it end to end, since the same discipline scales to all five.
- Naming convention.
checkout_complete, notCheckoutComplete,checkout-complete, ororderPlaced: consistentsnake_case, past-tense verb, and a stable name that survives UI copy changes (the button might say "Place order" today and "Buy now" tomorrow, the event name should not follow the button copy). - Required properties:
event_id: string (UUID),user_id: string,order_id: string,total_cents: integer,currency: string,item_count: integer,payment_method: enum('card','paypal','apple_pay','google_pay'),client_event_ts: timestamp,schema_version: integer. - Privacy and GDPR (General Data Protection Regulation, the EU's data-protection law). This event should NOT carry raw payment instrument data (card numbers, CVV, the card verification value printed on the card) at all, that belongs exclusively to the payment processor's PCI (Payment Card Industry) compliance-scoped systems, never the analytics pipeline. It also should not carry the user's full name, email, or shipping address as flat properties on an analytics event: those are personal data under GDPR, and putting them on a high-fan-out analytics event multiplies the number of systems a deletion request has to reach. The safer pattern is to reference identity via
user_idonly and let a deletion request cascade through a single identity-resolution point, rather than through every event table that happens to have denormalized the user's PII (personally identifiable information) onto it. If a property is genuinely needed for analysis and is itself PII (an email domain for B2B segmentation, for example), document it explicitly in the schema registry as PII-tagged so retention and deletion tooling can find and purge it on request, rather than discovering years later that it was never covered.
Trade-offs and pitfalls
- Client-only tracking of
purchaseis the single highest-risk shortcut. It looks fine in a demo and silently underreports whenever the app crashes between payment success and the event firing, or whenever a user is offline at the moment of purchase and the queued event never flushes; a revenue-critical event needs a server-side source of truth, with the client event used for early UX-level signal (like showing a live purchase counter), not for the number that reconciles against actual revenue. - Common mistake: treating
client_event_tsas authoritative for ordering. A batch of offline-queued events flushed on reconnect can haveclient_event_tsvalues that are minutes or hours apart but arrive at the server within the same second; sorting by arrival time or by client timestamp alone, instead of the session-scoped sequence number, produces a funnel that looks like users completed checkout before adding anything to their cart. - Under-scoping the schema-versioning cost. Adding
schema_versionafter a tracking plan is already live and has a year of un-versioned historical data is far more expensive than starting with it: retrofitting requires either backfilling a version onto old rows (often ambiguous, since you cannot always tell which version an old row was written under) or maintaining permanent special-case logic for "pre-versioning" data. Start every new event with a version field from day one, even a v1, even if it never needs to change. - Over-instrumenting client-side PII "just in case." Denormalizing user attributes onto every event because a future analysis MIGHT need them creates real privacy-compliance surface area (more systems to purge on a deletion request) for speculative value; prefer joining to a slim, well-governed user dimension table at query time over duplicating PII onto high-volume event streams.
SQL diagnostic (medium): Given daily funnel counts for the past 8 weeks, write a SQL query (or describe the approach) to detect whether the increase in drop-off between Step X and Step Y in the last 7 days is statistically significant compared to the prior 3 weeks. Explain which statistical test you would use and why.
Sample Answer
Direct answer
Aggregate step-X-to-step-Y drop-off into two windows, the last 7 days and the prior 3 weeks (21 days), then run a two-proportion z-test comparing the drop-off RATE (not the raw count) between the two windows: if the p-value falls below your alpha and the confidence interval for the difference excludes zero, the increase is statistically significant, not just noise. A two-proportion z-test is the right test here because drop-off is a binary per-user outcome (did they proceed or not) aggregated into a rate, which is exactly the shape a two-proportion test is built for; a chi-square test of independence on the same 2x2 table would give an equivalent conclusion, since a two-sided two-proportion z-test and a 2x2 chi-square test are algebraically the same test viewed two ways.
Structured elaboration
Why a two-proportion test, and why not something else. Daily funnel counts give, for each day, how many users reached step X and how many of those reached step Y; aggregated over a window, this is a count of successes (reached Y) out of a count of trials (reached X), a binomial-shaped quantity. Comparing two such rates (recent window vs. prior window) for a real difference is precisely the two-proportion z-test's use case. This is deliberately narrower than general time-series anomaly or change-point detection (control charts, CUSUM, cumulative sum control charting that tracks a running sum of deviations from a baseline to catch a sustained small shift, and Bayesian change-point models): those tools are built to monitor an arbitrary metric continuously over time and flag a shift wherever it occurs, appropriate general-purpose monitoring infrastructure for ANY metric. This question is scoped tighter: one specific step-pair, one specific recent-window-versus-baseline comparison, already identified as the thing to check, which is exactly what a single hypothesis test is for, no continuous monitoring machinery required.
The two windows. Recent: the last 7 days. Prior baseline: the 3 weeks (21 days) immediately preceding the recent window, adjacent, not overlapping and not gapped, so the comparison is "how has this week performed relative to the established recent baseline," not confounded by an arbitrary gap between the two periods.
The test, formally. Let precent and pprior be the drop-off rates (fraction of step-X users who did NOT reach step Y) in each window, with xrecent, xprior the step-X user counts in each window. The null hypothesis is that the true drop-off rate is the same in both windows:
H0:precent=ppriorPooled proportion and pooled standard error, used for the hypothesis test itself:
p^pool=xrecent+xpriorfailrecent+failprior,SEpool=p^pool(1−p^pool)(xrecent1+xprior1) z=SEpoolprecent−ppriorThe confidence interval for the DIFFERENCE uses the unpooled standard error instead (standard practice, since a confidence interval should not assume the null is true the way the hypothesis test's pooled SE does):
SEunpooled=xrecentprecent(1−precent)+xpriorpprior(1−pprior),CI95%=(precent−pprior)±1.96⋅SEunpooledWorked example
Synthetic 8 weeks (56 days) of daily step-X and step-Y counts, engineered with a stable ~40% baseline drop-off rate for the first 49 days and a genuine, injected regression to ~48% drop-off for the final 7 days (simulating, for example, a checkout-page bug shipped a week ago).
SQL aggregation (executed against SQLite as a stand-in for the target warehouse engine):
CREATE TABLE daily_funnel_counts (day TEXT, step_x_users INTEGER, step_y_users INTEGER);
INSERT INTO daily_funnel_counts VALUES
('2026-01-01',4879,2879),
('2026-01-02',4872,2832),
('2026-01-03',5276,3144),
('2026-01-04',4760,2794),
('2026-01-05',5037,3036),
('2026-01-06',5212,3121),
('2026-01-07',5064,2952),
('2026-01-08',5230,3085),
('2026-01-09',5002,3091),
('2026-01-10',4901,2887),
('2026-01-11',4986,2941),
('2026-01-12',4766,2855),
('2026-01-13',5102,3006),
('2026-01-14',5104,3098),
('2026-01-15',5197,3126),
('2026-01-16',5155,2999),
('2026-01-17',5055,2952),
('2026-01-18',4826,2981),
('2026-01-19',4976,2935),
('2026-01-20',5061,3054),
('2026-01-21',4825,2834),
('2026-01-22',4702,2794),
('2026-01-23',4773,2861),
('2026-01-24',5143,3000),
('2026-01-25',5021,3058),
('2026-01-26',5095,3124),
('2026-01-27',5041,2969),
('2026-01-28',5292,3201),
('2026-01-29',5000,2991),
('2026-01-30',5000,2991),
('2026-01-31',5000,2991),
('2026-02-01',5000,2991),
('2026-02-02',5000,2991),
('2026-02-03',5000,2991),
('2026-02-04',5000,2991),
('2026-02-05',5000,2991),
('2026-02-06',5000,2991),
('2026-02-07',5000,2991),
('2026-02-08',5000,2991),
('2026-02-09',5000,2991),
('2026-02-10',5000,2991),
('2026-02-11',5000,2991),
('2026-02-12',5000,2991),
('2026-02-13',5000,2991),
('2026-02-14',5000,2991),
('2026-02-15',5000,2991),
('2026-02-16',5000,2991),
('2026-02-17',5000,2991),
('2026-02-18',5020,3011),
('2026-02-19',4986,2601),
('2026-02-20',4986,2601),
('2026-02-21',4986,2601),
('2026-02-22',4986,2601),
('2026-02-23',4986,2601),
('2026-02-24',4986,2601),
('2026-02-25',4992,2607);
WITH bounds AS (
SELECT MAX(day) AS max_day FROM daily_funnel_counts
),
recent AS (
SELECT SUM(step_x_users) AS x_total, SUM(step_y_users) AS y_total, COUNT(*) AS n_days
FROM daily_funnel_counts, bounds
WHERE day > date(bounds.max_day, '-7 days')
),
prior AS (
SELECT SUM(step_x_users) AS x_total, SUM(step_y_users) AS y_total, COUNT(*) AS n_days
FROM daily_funnel_counts, bounds
WHERE day <= date(bounds.max_day, '-7 days')
AND day > date(bounds.max_day, '-28 days')
)
SELECT 'recent_7d' AS window, x_total, y_total, n_days,
ROUND(1.0 - 1.0 * y_total / x_total, 4) AS dropoff_rate
FROM recent
UNION ALL
SELECT 'prior_21d' AS window, x_total, y_total, n_days,
ROUND(1.0 - 1.0 * y_total / x_total, 4) AS dropoff_rate
FROM prior;
Output (actually executed):
window x_total y_total n_days dropoff
recent_7d 34908 18213 7 0.4783
prior_21d 105020 62831 21 0.4017
Significance test, computed in Python from the aggregated counts above (actually executed):
import math
x_recent, y_recent, n_recent = 34908, 18213, 7
x_prior, y_prior, n_prior = 105020, 62831, 21
fail_recent = x_recent - y_recent
fail_prior = x_prior - y_prior
p_recent = fail_recent / x_recent
p_prior = fail_prior / x_prior
diff = p_recent - p_prior
p_pool = (fail_recent + fail_prior) / (x_recent + x_prior)
se_pool = math.sqrt(p_pool * (1 - p_pool) * (1 / x_recent + 1 / x_prior))
z = diff / se_pool
p_value = math.erfc(abs(z) / math.sqrt(2))
se_unpooled = math.sqrt(p_recent * (1 - p_recent) / x_recent + p_prior * (1 - p_prior) / x_prior)
ci_lo = diff - 1.96 * se_unpooled
ci_hi = diff + 1.96 * se_unpooled
print(f"Drop-off rate, recent 7 days: {p_recent:.4f} ({fail_recent:,}/{x_recent:,})")
print(f"Drop-off rate, prior 21 days: {p_prior:.4f} ({fail_prior:,}/{x_prior:,})")
print(f"Observed difference (recent - prior): {diff:.4f}")
print(f"Pooled proportion: {p_pool:.4f}")
print(f"Pooled SE: {se_pool:.6f}")
print(f"z statistic: {z:.3f}")
print(f"two-tailed p-value: {p_value:.6f}")
print(f"95% CI for the difference (unpooled SE): [{ci_lo:.4f}, {ci_hi:.4f}]")
print(f"CI excludes 0: {ci_lo > 0 or ci_hi < 0}")
alpha = 0.05
print(f"At alpha={alpha}: {'REJECT H0 (drop-off increase is statistically significant)' if p_value < alpha else 'FAIL TO REJECT H0'}")
Output (actually executed):
Drop-off rate, recent 7 days: 0.4783 (16,695/34,908)
Drop-off rate, prior 21 days: 0.4017 (42,189/105,020)
Observed difference (recent - prior): 0.0765
Pooled proportion: 0.4208
Pooled SE: 0.003050
z statistic: 25.093
two-tailed p-value: 0.000000
95% CI for the difference (unpooled SE): [0.0705, 0.0826]
CI excludes 0: True
At alpha=0.05: REJECT H0 (drop-off increase is statistically significant)
The 7.65-percentage-point increase (40.2% to 47.8%) is overwhelmingly significant here (z well above the roughly 1.96 threshold for α=0.05, p-value effectively zero, confidence interval [7.05, 8.26] percentage points, comfortably excluding zero), consistent with the injected regression being large relative to this funnel's daily traffic volume.
Confidence-interval extension. The 95% confidence interval above, [0.0705, 0.0826], is the direct answer to "how much did drop-off actually increase, with uncertainty," a more complete and more decision-useful statement than the p-value alone: a p-value only says whether the increase is distinguishable from zero, while the confidence interval says how big it plausibly is, which is what determines whether the underlying cause is worth an urgent fix versus a lower-priority investigation.
Complexity
The SQL aggregation is two O(d) scans over d days of pre-aggregated daily counts (56 rows here), trivial at this grain regardless of how many raw events fed into those daily counts upstream; the actual cost lives in whatever job produced the daily rollup, not in this query. The z-test and confidence-interval computation are O(1) arithmetic once the two window totals are in hand.
Edge cases
- A step-X count of zero in either window makes the drop-off rate undefined (division by zero); guard for this explicitly (skip or flag the window) rather than letting the query silently return NULL or error deep in a dashboard pipeline.
- Very small counts in either window (a low-traffic step, or a short window) push the normal approximation the z-test relies on toward invalidity; a common rule of thumb is requiring at least 5 to 10 expected successes and failures in each group, worth checking before trusting the p-value at low volume.
- A window boundary that splits a single day (a partial day of data at the report's exact cutoff) can bias the comparison if included inconsistently; anchoring cleanly on whole calendar days, as the worked example does, avoids this.
Trade-offs and pitfalls
- Common mistake: comparing raw drop-off COUNTS between the two windows instead of RATES. Because the windows have different total traffic (34,908 users in 7 days versus 105,020 in 21 days), a naive count comparison is meaningless without normalizing by the number of users who reached step X in each window; the rate, not the count, is the quantity the hypothesis is actually about.
- Traffic-mix confounds. If the recent 7 days happens to include a different marketing-channel mix, device mix, or day-of-week composition than the prior 21 days (a holiday, a new ad campaign launch), the significant difference detected here could reflect a MIX shift rather than a genuine step-level regression; segmenting the same test by channel or device before concluding "the step itself broke" is a reasonable next check.
- A statistically significant result at this scale does not by itself prove urgency. With tens of thousands of users per window, even a modest, business-marginal shift can reach significance; pair the p-value and confidence interval with the ABSOLUTE revenue or user impact of the shift before treating it as a fire drill.
- This test is intentionally narrow, not a substitute for general monitoring. It answers "is THIS specific, already-suspected step-pair's recent change real," on demand, for one comparison. It is not a replacement for continuous anomaly-detection infrastructure that watches every step-pair automatically and flags whichever one moves: that is a general-purpose monitoring system, built once with control charts or change-point models and run continuously across every metric, not the single, already-scoped hypothesis test this question asks for.
Unlock Full Question Bank
Get access to all 39 Conversion Funnel Optimization interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.