Build Conversion Funnels Without Google Analytics
Build conversion funnels without Google Analytics: a first-party event schema, SQL for step conversion and drop-off, and self-hosted tools on your own data.
To build conversion funnels without Google Analytics, record first-party events with a visitor or session ID and a timestamp, then compute ordered step completion in SQL — or use a self-hosted analytics tool with funnels built in. GA4 can do funnels, but they live in exploration reports that need custom event setup, can take 24–48 hours of processing, apply thresholding, sample very large queries, and keep detailed exploration data for at most 14 months on standard properties — on Google's servers, not yours.
Most developers never configure GA4 funnels at all. Yet knowing exactly where users drop off between landing on your site and completing a purchase is arguably the most valuable metric you can track. A well-built funnel doesn't just show conversion rates — it shows which screen, which form field, which loading spinner is costing you revenue.
This guide covers what conversion funnels actually measure, how to design event schemas for funnel tracking, SQL patterns for computing step-by-step conversion, drop-off visualization, and how first-party analytics can replace GA4's funnel reports entirely.
TL;DR: Conversion funnels track where users drop off between key steps like signup and payment. GA4 funnels need custom event setup, can take 24–48 hours to process, and live in exploration reports with thresholding, sampling on very large queries and limited retention. You can build your own funnel system with PostgreSQL window functions, or use a platform like Temps that includes funnel analytics out of the box with
overall_conversion_rate, per-stepdrop_off_rate, andaverage_completion_time_seconds, computed from raw events on your own server.
Funnel Analytics Platforms: Temps vs PostHog vs DIY PostgreSQL
| Capability | Temps | PostHog (self-hosted) | DIY PostgreSQL |
|---|---|---|---|
| Multi-step funnels | Yes — built in | Yes | Yes (manual SQL) |
| Per-step drop-off rate | Yes — automatic | Yes | Manual query |
| Average time per step | Yes | Yes | Manual query |
| Retroactive funnels | Yes | Yes | Yes |
| Session replay in the same tool | Yes (opt-in) | Yes | No |
| Self-host requirement | Runs with your deployments (Temps + TimescaleDB) | 4 vCPU / 16 GB RAM (hobby deploy) | Your existing Postgres |
| Price | Free (self-host); Temps Cloud coming soon from $29/mo | Free (self-host) / Free tier (Cloud) | Infra cost only |
| Open-source license | MIT / Apache 2.0 | MIT (enterprise code separately licensed) | N/A |
| Single binary | Yes | No | No |
What Is a Conversion Funnel?
A conversion funnel maps the sequential steps users take from first touch to desired outcome. Most visitors leave without taking action — and a funnel tells you exactly where.
The simplest funnel has four steps:
- Visit — user lands on your site
- Signup — user creates an account
- Activation — user completes a key action (first deployment, first project, first integration)
- Payment — user converts to a paid plan
Each step has a drop-off rate. If 1,000 people visit, 100 sign up, 40 activate, and 8 pay — your visit-to-signup rate is 10%, signup-to-activation is 40%, and activation-to-payment is 20%. The overall funnel conversion is 0.8%.
Why Drop-Off Matters More Than Conversion Rate
Overall conversion rate is a vanity metric. It tells you the end result but hides where the problem lives. If your visit-to-signup rate is 15% but signup-to-activation is 12%, you don't have a traffic problem. You have an onboarding problem.
Drop-off analysis pinpoints the exact step costing you money. Fix the worst drop-off first — that's where marginal effort produces the largest revenue gain. A 5% improvement at your worst step compounds through every step below it.
What Real Funnel Numbers Look Like
Benchmarks vary widely by market, price point and traffic source, so treat these as rough illustrative ranges rather than targets:
| Step | Typical Rate | Good Rate |
|---|---|---|
| Visit → Signup | 2-5% | 8-12% |
| Signup → Activation | 20-40% | 50-70% |
| Activation → Paid | 15-30% | 40-60% |
| Visit → Paid (overall) | 0.5-2% | 3-5% |
Compare each of your steps with its own history first; outside benchmarks are a sanity check, not a goal.
Why Are GA4 Funnels So Painful?
GA4 does have funnels — the funnel exploration — but they come with real limits: every step needs a configured event, explorations are subject to thresholding and sampling, processing can take a day or two, and the detailed data sits in Google's account with capped retention.
Event Configuration Is a Full-Time Job
In Universal Analytics, goals were simple — define a URL pattern or event category, and you're done. GA4 replaced this with a fully event-driven model. Every funnel step needs an event, and custom parameters need registering as custom dimensions in the GA4 admin before they show up in reports.
Want a checkout funnel? You need to fire begin_checkout, add_shipping_info, add_payment_info, and purchase events — each with recommended parameters like currency, value, and items arrays. Miss one parameter and that step silently breaks.
Thresholding and Sampling
GA4 applies thresholding to reports when Google signals or demographics are involved, hiding rows with small user counts — exactly the small cohorts you often want in a funnel. On standard properties, explorations are also sampled once a query covers more than 10 million events (Google's sampling docs). Google shows a data-quality icon in the corner of the report, but most users never notice it.
What does sampling mean in practice? Google takes a subset of your data and extrapolates. For a funnel where precision matters — where 3% vs 5% conversion is the difference between a viable business and a failed one — an estimate isn't good enough. Most small sites never hit the 10-million-event threshold, but thresholding can bite much earlier.
Processing Delay
Google says GA4 data processing can take 24–48 hours, and reports can change during that window. Realtime reports are fast but don't support funnels.
Push a change to fix a checkout drop-off and you may wait a day or two for trustworthy numbers. That feedback loop is slow for iterative product work.
Your Data, Their Rules
GA4 funnel explorations can be applied to historical data (Google's funnel exploration docs), but only as far back as your exploration retention setting: two months by default and 14 months at most on standard properties. Raw events stay in Google's account unless you export them to BigQuery. A self-hosted events table has no such cap — you decide how long to keep it.
What Data Model Do Conversion Funnels Need?
Building your own funnel system starts with one table: an events table with a user identifier, event name, timestamp, and optional properties. PostgreSQL is the most practical choice for a funnel backend: you probably already run it, and window functions do the heavy lifting.
The Events Table
Here's the minimum schema:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
event_name TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
properties JSONB DEFAULT '{}'
);
CREATE INDEX idx_events_user_time
ON events (user_id, timestamp);
CREATE INDEX idx_events_name
ON events (event_name);
That user_id can be an authenticated user ID, an anonymous session token, or a fingerprint hash — whatever identifier you use to track a single person across steps. The properties column holds arbitrary metadata: page URL, plan name, referral source, anything you might want to filter on later.
The Funnel Query Logic
A funnel query answers one question: how many users completed step N within a time window after completing step N-1? The SQL pattern uses CTEs to order events per user, then filters for sequential completion.
Here's the core query for a three-step funnel (visit, signup, payment):
WITH step_1 AS (
SELECT DISTINCT ON (user_id)
user_id, timestamp AS step_1_at
FROM events
WHERE event_name = 'page_view'
AND timestamp >= NOW() - INTERVAL '30 days'
ORDER BY user_id, timestamp
),
step_2 AS (
SELECT DISTINCT ON (s1.user_id)
s1.user_id, e.timestamp AS step_2_at
FROM step_1 s1
JOIN events e ON e.user_id = s1.user_id
AND e.event_name = 'signup'
AND e.timestamp > s1.step_1_at
AND e.timestamp < s1.step_1_at + INTERVAL '7 days'
ORDER BY s1.user_id, e.timestamp
),
step_3 AS (
SELECT DISTINCT ON (s2.user_id)
s2.user_id, e.timestamp AS step_3_at
FROM step_2 s2
JOIN events e ON e.user_id = s2.user_id
AND e.event_name = 'payment'
AND e.timestamp > s2.step_2_at
AND e.timestamp < s2.step_2_at + INTERVAL '30 days'
ORDER BY s2.user_id, e.timestamp
)
SELECT
(SELECT COUNT(*) FROM step_1) AS step_1_count,
(SELECT COUNT(*) FROM step_2) AS step_2_count,
(SELECT COUNT(*) FROM step_3) AS step_3_count;
Each CTE filters for users who completed the previous step and then completed the current step within a defined time window. The time window prevents counting a user who signed up a year ago and paid today as a single funnel conversion.
Most funnel implementations skip time-window constraints between steps. Without them, your funnel data becomes meaningless at scale — a user who visited in January and paid in December inflates your conversion rate even though they represent a completely different decision journey.
Why Time Windows Matter
Without time constraints, you're measuring "did this user ever do both things?" rather than "did this user progress through a coherent journey?" A 7-day window between visit and signup is reasonable. A 30-day window between signup and payment fits most SaaS trial periods.
Adjust these windows based on your sales cycle. B2B products with longer evaluation periods might need 60-90 day windows. Consumer products with impulse-buy patterns might use 24-hour windows.
How Do You Build a DIY Funnel System?
A working funnel system needs three components: an event tracking endpoint, storage in PostgreSQL, and a query layer that computes step-by-step conversion.
Step 1: Event Collection Endpoint
Here's a minimal Express.js endpoint that accepts events:
const express = require('express');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const app = express();
app.use(express.json());
app.post('/api/events', async (req, res) => {
const { user_id, event_name, properties } = req.body;
if (!user_id || !event_name) {
return res.status(400).json({ error: 'Missing required fields' });
}
await pool.query(
`INSERT INTO events (user_id, event_name, properties)
VALUES ($1, $2, $3)`,
[user_id, event_name, JSON.stringify(properties || {})]
);
res.status(201).json({ ok: true });
});
app.listen(3001);
Keep this endpoint fast. Event tracking should never block the user experience. Insert and return immediately — do any enrichment (geolocation, device detection) asynchronously.
Step 2: Client-Side Event Firing
Track events from your frontend with simple fetch calls:
function trackEvent(eventName, properties = {}) {
const userId = getUserId(); // from auth or anonymous cookie
navigator.sendBeacon('/api/events', JSON.stringify({
user_id: userId,
event_name: eventName,
properties
}));
}
// Usage
trackEvent('page_view', { path: window.location.pathname });
trackEvent('signup', { method: 'github' });
trackEvent('onboarding_complete', { steps_skipped: 0 });
trackEvent('payment', { plan: 'pro', amount: 19 });
Using sendBeacon instead of fetch ensures the event fires even if the user navigates away. It's non-blocking and survives page transitions.
Step 3: Funnel Query Endpoint
Wrap the SQL from the previous section in an API endpoint:
app.post('/api/funnel', async (req, res) => {
const { steps, window_days = 30 } = req.body;
// steps: ['page_view', 'signup', 'payment']
const ctes = steps.map((step, i) => {
if (i === 0) {
return `step_${i} AS (
SELECT DISTINCT ON (user_id) user_id, timestamp AS t
FROM events
WHERE event_name = '${step}'
AND timestamp >= NOW() - INTERVAL '${window_days} days'
ORDER BY user_id, timestamp
)`;
}
return `step_${i} AS (
SELECT DISTINCT ON (s.user_id) s.user_id, e.timestamp AS t
FROM step_${i - 1} s
JOIN events e ON e.user_id = s.user_id
AND e.event_name = '${step}'
AND e.timestamp > s.t
AND e.timestamp < s.t + INTERVAL '${window_days} days'
ORDER BY s.user_id, e.timestamp
)`;
});
const counts = steps.map((_, i) =>
`(SELECT COUNT(*) FROM step_${i}) AS step_${i}_count`
).join(', ');
const query = `WITH ${ctes.join(', ')} SELECT ${counts}`;
const result = await pool.query(query);
res.json(result.rows[0]);
});
Warning: This example concatenates user input into SQL for clarity. In production, use parameterized queries or a query builder to prevent SQL injection.
Step 4: Drop-Off Visualization
A funnel visualization doesn't need a charting library. Horizontal bars with CSS work fine to start:
<div class="funnel">
<div class="step" style="width: 100%">
<span>Page View: 10,000</span>
</div>
<div class="step" style="width: 12%">
<span>Signup: 1,200 (12%)</span>
</div>
<div class="step" style="width: 4.8%">
<span>Activation: 480 (40%)</span>
</div>
<div class="step" style="width: 1.4%">
<span>Payment: 144 (30%)</span>
</div>
</div>
We've found that showing the percentage relative to the previous step (not the first step) makes drop-off analysis more actionable. A "30% activation-to-payment" tells you more than a "1.4% overall conversion" when you're deciding what to fix next.
How Do You Compare Cohorts Inside a Funnel?
Cohort comparison reveals whether changes actually improve conversion — or just shift the numbers around. Testing against cohorts is what separates opinion from evidence.
Splitting Funnels by Cohort
Add a cohort filter to your funnel query to compare groups. The most common splits are:
- Time cohorts — users who signed up this week vs last week
- Source cohorts — organic vs paid traffic
- Feature cohorts — users who saw variant A vs variant B
- Plan cohorts — free tier vs trial users
The SQL is the same funnel query with a WHERE clause on properties:
-- Compare funnel conversion for organic vs paid users
WITH step_1 AS (
SELECT DISTINCT ON (user_id)
user_id, timestamp AS step_1_at,
properties->>'utm_source' AS source
FROM events
WHERE event_name = 'page_view'
AND timestamp >= NOW() - INTERVAL '30 days'
ORDER BY user_id, timestamp
),
-- ... remaining steps same as before ...
SELECT
source,
COUNT(*) FILTER (WHERE step >= 1) AS visited,
COUNT(*) FILTER (WHERE step >= 2) AS signed_up,
COUNT(*) FILTER (WHERE step >= 3) AS paid
FROM funnel_users
GROUP BY source;
Integrating A/B Tests with Funnels
If you're running A/B tests, store the variant assignment as an event property. When a user enters the experiment, fire an event like experiment_assigned with { experiment: 'onboarding_v2', variant: 'control' }. Then filter your funnel by that property.
This gives you funnel conversion rates broken down by experiment variant, computed on raw, unsampled data as soon as the events land — no processing delay and no separate experimentation tool.
What Open-Source Funnel Tools Exist?
PostHog, Plausible, and Umami are the three best-known open-source analytics platforms, but they differ a lot in funnel support and in what it takes to self-host them.
PostHog: Full Funnels, Heavy Infrastructure
PostHog is the closest open-source equivalent to Mixpanel or Amplitude. It offers multi-step funnels, funnel breakdowns by property, conversion time analysis, and correlation analysis to identify what properties predict conversion.
The catch? Self-hosting PostHog means its Docker Compose "hobby" deployment — PostgreSQL, ClickHouse, Redis, Kafka, and the PostHog application — on roughly 4 vCPU and 16 GB of RAM, and PostHog suggests its cloud once you pass about 300k events a month (PostHog self-host docs). New Kubernetes deployments are no longer supported. That's a real server, not the smallest VPS.
PostHog Cloud offers a generous free tier (1 million events a month), but then you're sending data to a third party again. See Temps vs PostHog for a side-by-side.
Plausible: Funnels on Business, Not in Community Edition
Plausible tracks goals — page visits and custom events that you define as conversions. Plausible Cloud adds funnel analysis on its Business plan, but funnels aren't available in the self-hosted Community Edition.
Plausible's strength is simplicity. It runs on Elixir and ClickHouse, and gives you clean dashboards with almost no configuration. If you need funnels and want to self-host, it's the wrong tool.
Umami: Lightweight, With a Funnel Report
Umami is a lightweight, privacy-focused analytics tool built on Node.js and PostgreSQL. It tracks page views, referrers, devices, and custom events, and its funnel report counts visitors who reach URLs or events in order within a time window you set.
Umami runs on modest resources and is the lightest self-hosted option here with funnels. It doesn't include error tracking. Session replay and heatmaps were added in v3.1 and v3.2 (2026).
How They Compare
| Feature | Temps | PostHog | Plausible | Umami |
|---|---|---|---|---|
| Multi-step funnels | Yes | Yes | Cloud Business plan only | Yes (funnel report) |
| Per-step filters | Yes (path, UTM, referrer, device, custom properties) | Yes | — | Limited |
| Conversion time analysis | Yes (average_completion_time_seconds) | Yes | — | No |
| Per-step drop-off rate | Yes (automatic) | Yes | Cloud Business only | Yes |
| Session replay built in | Yes (opt-in) | Yes | No | Yes (v3.1+) |
| Self-host footprint | Runs with your deployments (Temps + TimescaleDB) | 4 vCPU / 16 GB (hobby) | PostgreSQL + ClickHouse | Node.js + PostgreSQL |
| Custom event tracking | Yes | Yes | Yes | Yes |
| Funnels over past data | Yes (stored events) | Yes | Cloud only | Yes |
| License | MIT / Apache 2.0 | MIT (enterprise code separate) | AGPL-3.0 | MIT |
Why Does First-Party Tracking Produce Better Funnel Data?
Analytics served from your own domain is blocked less often than a third-party script, so more of your real traffic reaches step one. Plausible compared GA with its own counts on one tech-audience site in 2021 and found GA missed roughly 58% of visitors. Your audience will differ, but the direction is consistent: the more technical the audience, the bigger the gap. For the setup itself, see web analytics without third-party scripts.
Ad Blockers Create Invisible Holes in Your Funnel
If a meaningful share of your top-of-funnel visitors is missing, your step-1 count is wrong, and every downstream conversion rate is calculated against a deflated denominator. The blocked visitors don't disappear evenly either — technical users block more — so the distortion varies by channel.
Consent Still Applies
Under GDPR and the ePrivacy rules, GA4 generally needs consent before it loads, and visitors who decline drop out of your funnel. First-party tooling doesn't remove that obligation automatically: Temps' proxy sets two first-party cookies on your own domain (_temps_visitor_id and _temps_sid), so EU sites generally need consent for them too — the data ownership and privacy docs explain the options. What first-party serving does change is the ad-blocker gap: the analytics endpoint lives on your own domain under /api/_temps, so blocklists that target known third-party analytics hosts don't match it.
How Does Temps Handle Conversion Funnels?
Temps includes funnel analytics as part of its built-in web analytics — the same infrastructure that handles web analytics, session replay, and error tracking. Since Temps already stores every page view and custom event in TimescaleDB, funnels are a query on data you're already collecting. No additional setup, no separate analytics stack, no extra cost.
Three things follow from that design:
- Funnels over data you already have: Temps stores raw events, not pre-aggregated counts, so a funnel you define today runs against events already in the database.
- Drop-off per step: The API returns
drop_off_rateandconversion_rateper step andoverall_conversion_ratefor the whole funnel, so the worst step is easy to spot. - One dashboard for numbers and replays: Funnels, opt-in session replay and error tracking live in the same project, so you can move from a drop-off to the sessions and errors behind it without exporting data to a second tool.
The reason most deployment platforms don't include analytics is historical — hosting and analytics evolved as separate product categories. But they share the same infrastructure: an HTTP endpoint, a database, and a server. Combining them eliminates an entire class of integration work that developers accept as normal.
Defining Funnel Steps
In the Temps dashboard's Funnels view (or through the API), you define funnel steps by selecting events your application already tracks, each with optional filters on page path, UTM values, referrer, device or a custom event property. No code changes required.
Because Temps stores raw events rather than pre-aggregated data, a new funnel works on events you've already collected. GA4 can also apply a funnel to past data, but only events GA4 was already collecting, within your exploration retention window.
Sending Events from a React App
Install the SDK and wrap your app with TempsAnalyticsProvider. The useTrackEvent hook sends any named event to Temps:
import { TempsAnalyticsProvider, useTrackEvent } from '@temps-sdk/react-analytics';
// In your root layout
export default function Layout({ children }) {
return (
<TempsAnalyticsProvider
domain="yourdomain.com"
basePath="/api/_temps"
enableSessionRecording={true}
>
{children}
</TempsAnalyticsProvider>
);
}
// In a component
function CheckoutButton() {
const trackEvent = useTrackEvent();
function handleClick() {
trackEvent('begin_checkout', { plan: 'pro', source: 'pricing_page' });
}
return <button onClick={handleClick}>Start checkout</button>;
}
Each trackEvent call becomes a row in Temps' event store. Build a funnel with steps page_view → begin_checkout → payment_complete and Temps calculates the conversion rate, per-step drop-off rate, and average time to complete for each step.
Automatic Drop-Off Calculation
Temps calculates drop-off between each step. If your signup-to-activation rate is 18% while your activation-to-payment rate is 55%, the per-step numbers make the signup-to-activation step your obvious bottleneck. Funnel metrics can be filtered by environment, country and date range.
The API response includes step_conversions, where each step carries conversion_rate, drop_off_rate, and average_time_to_complete_seconds. The top-level overall_conversion_rate and average_completion_time_seconds give you the funnel's headline numbers.
Combining Funnels with Session Replay
Here's where an integrated platform pays off. When you identify a drop-off — say, 60% of users abandon the onboarding step — you can open session replays from the same project and watch sessions on that page. You don't need to correlate IDs between Mixpanel and FullStory. It's the same system. Replay is opt-in, masks all inputs by default and records the DOM and user input (session replay docs).
Did users click a button that did nothing? Did they scroll past the CTA without seeing it? Did a JavaScript error crash the form? Replay shows the first two; Temps' error tracking, in the same project, catches the third. Funnel numbers alone never can.
FAQ
How do you build conversion funnels without Google Analytics?
Record first-party events with a visitor or session ID and a timestamp, then compute ordered step completion. Three ways to do it: (1) DIY with PostgreSQL — create an events table, fire events from your frontend, and query sequential completion with CTEs and time windows; (2) a self-hosted analytics tool with funnels, such as PostHog (about 4 vCPU / 16 GB for its hobby deploy) or Umami's funnel report; (3) Temps — funnel analytics built into the deployment platform, dual MIT / Apache 2.0, free to self-host.
Each approach avoids GA4's processing delay, thresholding and sampling. First-party storage means you own the data and decide how long to keep it.
How many funnel steps should you track?
Keep funnels between 3 and 7 steps. Fewer than 3 doesn't give you enough resolution to find bottlenecks. More than 7 creates noise — every step adds a conversion rate to monitor, and the numbers get too small to be statistically meaningful. Simpler journeys tend to convert better anyway, so a long funnel is often a sign the flow itself needs trimming.
Can you build funnels without user authentication?
Yes. Anonymous identifiers work — a random UUID stored in a first-party cookie or localStorage. The identifier just needs to persist across page views within a session. For cross-session funnels (visit today, sign up tomorrow), you need an identifier that survives browser restarts, which typically means a cookie with a 30-day expiry. Authenticated user IDs are more reliable, but anonymous tracking covers most funnel use cases.
What's a good conversion rate for SaaS funnels?
It depends on the step, the market and the traffic source, so published benchmarks vary widely. Free-trial products usually convert to paid at a higher rate than freemium ones, because the trial filters for intent. Don't compare your overall funnel rate to someone else's — track each step against its own history and fix the worst one first.
Do funnels work for mobile apps?
Yes. The same event-based model applies. Fire events from your mobile app to the same collection endpoint and run the same funnel queries. The key difference is session handling — mobile apps have background/foreground states rather than page navigations, so your "session start" event might be an app-open event rather than a page view. The funnel query logic stays identical.
Is Temps funnel analytics free?
Yes. Temps is dual-licensed MIT / Apache 2.0 — self-host for free on your own server, with no per-seat fees, no per-event fees, and no bandwidth billing. Funnel analytics, session replay, error tracking, and uptime monitoring are all included. Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month.
Stop Guessing Where Users Drop Off
Conversion funnels aren't complicated. They're a sequence of counts: how many users did step one, how many of those did step two, and so on. The hard part has always been the tooling — GA4 makes it needlessly complex, and self-hosted alternatives either lack the feature or require a heavy infrastructure stack.
You have three realistic options. Build it yourself with PostgreSQL and the SQL patterns from this guide — it works and you own every piece. Use PostHog if you want a full-featured open-source analytics suite and don't mind its 16 GB RAM requirement. Or use a deployment platform like Temps that includes funnel analytics alongside web analytics, session replay, and error tracking without adding another service to your stack.
The metric that matters isn't your overall conversion rate. It's the drop-off at your worst funnel step. Find that step, watch the session replays, fix the problem, and measure again. That loop — funnel, replay, fix, re-measure — is how you systematically grow revenue.
curl -fsSL https://temps.sh/deploy.sh | bash
Get weekly updates