×marble
all posts
Jun 17, 2026·13 min read

Building a Personalization Stack on Supabase: pgvector, Realtime, and Where It Breaks

Supabase bundles vector search, per-user RLS, and Edge Functions into one open-source backend. Here's a technical audit of what that stack actually handles for personalization — and the three gaps you'll hit before 500k DAU.

Alex Shrestha·Founder, ×marble

Building a Personalization Stack on Supabase: pgvector, Realtime, and Where It Breaks

TL;DR.

  • Supabase bundles pgvector, Row Level Security, Realtime, and Edge Functions — enough to ship a working personalization layer without a dedicated ML platform.
  • pgvector's HNSW index delivers sub-10ms p99 similarity retrieval up to roughly 5M vectors on managed Postgres; above that, dedicated vector stores pull ahead on throughput and recall.
  • RLS is a genuine personalization primitive: per-user signal isolation enforced at the database layer, zero application filtering logic required.
  • In 2026, the gaps that force you off Supabase are not storage or query latency — they are point-in-time feature correctness, stream-processed signal aggregation, and day-zero personalization bootstrapping.
  • If you only remember one thing: Supabase is production-ready for personalization at startup scale; the ceiling is not the database, it is the absence of a feature store and event pipeline.

Supabase has spent three years becoming the default backend for teams that want PostgreSQL without the operational overhead. What is less obvious is how far that stack extends into personalization — not just "store a user preference in a row", but embedding-based retrieval, signal decay, cross-session preference propagation, and serving-time feature assembly. By 2026, teams are routinely shipping recommendation surfaces, content feeds, and onboarding personalization on Supabase without touching a dedicated ML platform. This post is a technical audit: what the stack gives you, where it performs, the latency budgets you can realistically hit, and where you will run into walls before you expect to.

What does the Supabase personalization stack actually consist of?

The Supabase personalization stack is four database and infrastructure primitives that compose well together: pgvector for embedding storage and approximate nearest neighbor (ANN) retrieval, Row Level Security (RLS) for per-user data isolation, Realtime for propagating preference updates without polling, and Edge Functions for lightweight inference at the serving layer. None of these are purpose-built personalization primitives. They are general-purpose tools with specific properties that make them useful for this workload.

The critical constraint to understand upfront: PostgreSQL's execution model is synchronous and transactional. That is a feature for signal writes — you get consistency and durability — and a genuine constraint for high-throughput reads. Personalization workloads are heavily read-skewed: a single feed render may trigger three to five retrieval operations across different content surfaces. Supabase's connection pooler (PgBouncer in transaction mode) helps, but it does not change the fundamental sequential nature of Postgres query execution. Everything downstream flows from that constraint.

pgvector: What HNSW actually delivers at serving time

pgvector, at v0.8 in 2026, supports two index strategies: IVFFlat and HNSW (pgvector GitHub). IVFFlat partitions the vector space into Voronoi cells and probes a subset at query time — fast to build, memory-efficient, but recall degrades unless you increase probes, which increases latency proportionally. HNSW (Hierarchical Navigable Small World) builds a multi-layer proximity graph that trades higher build time and memory footprint for consistently high recall and predictable query latency across the full data distribution (Malkov & Yashunin, 2016).

For personalization serving, HNSW is almost always the right choice. The access pattern is: given a user's preference embedding, retrieve the top-K content items by cosine similarity from a catalog. That pattern is exactly what HNSW optimizes for. With default parameters (m=16, ef_construction=64), you get 0.95+ recall at under 10ms p99 for catalogs up to approximately 5M 1536-dimensional vectors on a Supabase Pro instance with a dedicated compute add-on.

Two HNSW parameters matter most for this workload:

  • m controls the number of bi-directional links per node during graph construction. Higher m improves recall at the cost of memory and build time. For content catalogs where recall matters more than build speed (items are ingested asynchronously), m=32 or m=64 is reasonable.
  • ef_search controls how many candidates the search explores at query time. It is a query-time parameter, not an index parameter — you can tune it per request. SET hnsw.ef_search = 100 before a query improves recall at the cost of ~2ms added latency; useful for high-stakes surfaces like onboarding feeds.

Benchmark context: ANN Benchmarks consistently shows HNSW achieving 0.99 recall at 1ms–5ms on dedicated hardware for 1M vectors. Supabase managed Postgres adds PgBouncer overhead and shared compute contention. Expect 2–4x latency inflation relative to bare-metal results, putting real-world p99 at 5–15ms for 1M vector catalogs on appropriately sized instances. That is within budget for recommendation candidate retrieval. It is not within budget for synchronous feature lookups that block page render on hot paths — those need a separate caching layer.

Where pgvector underperforms dedicated vector databases

The throughput ceiling is real and well-documented. Qdrant, Weaviate, and Pinecone implement SIMD-optimized distance kernels, purpose-built IO paths, and concurrent index scan support that pgvector does not have access to inside Postgres's shared-memory model. At 5k–10k similarity QPS, you will saturate Supabase Postgres's CPU long before you saturate a comparable Qdrant deployment.

The practical rule of thumb: catalogs under 2M items, applications under 200k DAU, pgvector is production-ready. Beyond those thresholds, evaluate a dedicated vector store for the retrieval layer and use Supabase as the metadata and user data layer. The two roles complement each other — Supabase for transactional consistency, a purpose-built vector store for retrieval throughput.

Row Level Security as a personalization primitive

RLS is one of Supabase's most underrated capabilities for personalization workloads. The conventional approach to per-user data isolation is application-layer filtering: every API handler adds WHERE user_id = $current_user_id to every query. This is fragile — one missed clause leaks data across users. RLS moves the policy into the database itself, enforced on every access path including direct Postgres connections, the Supabase client SDK, and Edge Functions (Supabase RLS docs).

CREATE POLICY "users see own signals"
  ON user_signals FOR SELECT
  USING (user_id = auth.uid());

With this policy active, SELECT * FROM user_signals from an authenticated client automatically returns only the rows belonging to the requesting user. No application logic required. For a personalization system with user_signals, preference_vectors, and interaction_history tables, RLS on all three means your serving code never has to think about data isolation — it is structurally impossible to return another user's signals.

RLS performance: the composite index requirement

RLS policies are evaluated per-row during table scans, before the results are returned to the application. If your user_signals table has 500M rows and your query triggers a sequential scan or an index scan that does not prune by user_id first, the policy check runs on every candidate row. The fix is non-negotiable: composite indexes that lead with user_id.

CREATE INDEX ON user_signals (user_id, signal_type, created_at DESC);

With this index, the Postgres planner prunes to a single user's rows before the RLS policy evaluation runs. The difference between a full-table scan with per-row policy checks and a composite-index scan is two to three orders of magnitude at scale. This is not a Supabase-specific issue — it is standard Postgres query planning — but it bites consistently when teams discover RLS "just works" at small scale and then skip index design.

Realtime: preference propagation without polling

Supabase Realtime uses PostgreSQL logical replication to stream row-level changes to connected clients over WebSocket (Supabase Realtime docs). For personalization, the relevant use case is preference propagation: when a user signals a strong preference — completing an article, saving an item, rating a piece of content — downstream surfaces should update their ranked lists without requiring a full page reload or a polling interval.

A concrete propagation flow:

  1. User completes a video. Client writes { user_id, item_id, event_type: 'complete', weight: 1.0 } to user_events.
  2. A Postgres trigger fires and updates the user's preference_vector in-place: new_vec = (1 - α) * old_vec + α * item_embedding where α controls recency weighting. α = 0.1 is a reasonable starting point for content discovery feeds.
  3. The preference_vectors row update is broadcast via Realtime to any subscribed client session for that user.
  4. The client re-scores its current candidate set using the updated preference vector, updating the feed ranking in-place.

End-to-end latency from write to Realtime broadcast is typically 50–200ms in production, depending on replication lag and client WebSocket round-trip. That is fast enough for content surfaces, too slow for gaming state or financial applications. The architectural constraint is that Realtime is a fan-out broadcast system, not a stream processor: it emits what changed, it does not compute windowed aggregates, apply decay schedules, or sessionize rapid event sequences. Debouncing logic — batching ten rapid clicks into a single preference signal — lives in your Edge Function or application, not in Realtime.

Edge Functions: serving-layer inference without a dedicated ML platform

Supabase Edge Functions run Deno on distributed infrastructure with cold-start times under 200ms and warm invocation latency under 10ms for compute-light functions (Supabase Edge Functions docs). For personalization, the primary use case is post-retrieval re-ranking: you have retrieved N candidates from pgvector via ANN search, and you want to apply a scoring function that incorporates signals beyond embedding similarity — recency, diversity, business rules, user context — before returning results to the client.

A concrete serving pattern:

POST /personalize { user_id, surface, limit }

1. Read user embedding from preference_vectors (Postgres read, ~3ms warm)
2. ANN retrieval:
   SELECT item_id, 1 - (embedding <=> $user_vec) AS similarity
   FROM items
   ORDER BY embedding <=> $user_vec
   LIMIT 100
   (~8ms p50 for 500k item catalog)
3. Batch fetch item features for top-100 candidates (~5ms)
4. Score each candidate:
   score = w1 * similarity
         + w2 * freshness_decay(published_at, now)
         + w3 * surface_affinity(item_category, user_preferred_categories)
5. Sort by score, return top-K

Warm end-to-end latency for this pattern — one preference vector read, one ANN query, one batch feature fetch, in-process scoring — is approximately 20–40ms when the Edge Function PoP is co-located with the Supabase region. Cross-region adds 30–80ms of network transit. This is the single most important operational decision for latency-sensitive personalization: choose your Supabase region to match your primary user geography. Edge Functions do not automatically route requests to the nearest database replica — they route to the nearest Edge Function PoP, which then makes a network hop to the database region you provisioned.

What "lightweight model" means in an Edge Function context matters precisely. A dot-product re-ranker, a logistic regression over hand-crafted features, or a small MLP exported to ONNX can all run in Deno within this latency budget. A transformer-based re-ranker with more than ~50M parameters cannot — the compute budget for a synchronous Edge Function is measured in milliseconds of CPU, not GPU seconds.

The three gaps you will hit before 500k DAU

This is where the audit becomes specific. Supabase is a backend platform, not a personalization platform. Three gaps become load-bearing as the user base grows.

1. No point-in-time correct feature store

A feature store provides pre-computed user and item features at inference time with point-in-time correctness: the features used for online serving are consistent with the features used to train the model. Supabase Postgres is a transactional database — it serves current-state features, not historical snapshots.

For early-stage products, this does not matter. You are not running offline training at scale, and simple models tolerate feature drift between training and serving. The constraint becomes real the moment you want to backfill training data for a new model version, run offline A/B experiments, or answer a compliance audit question: "What features did your system use when it recommended this item to this user on this date?" Point-in-time correctness requires either a shadow table pattern (every feature write appends a versioned record with a timestamp, enabling historical point-in-time queries) or a purpose-built store. Supabase gives you the former as a buildable pattern; it does not provide the latter.

2. No stream processing for signal aggregation

Personalization signals are streams: clicks, hovers, scroll depth, completions, skips, shares. Supabase can ingest these events into a Postgres table efficiently. What it does not provide is windowed aggregation, sessionization, or decay-weighted rollups. If your re-ranking model needs a "weighted interaction count over the last 7 days, decayed by recency" feature for each user-item pair, you need to compute that somewhere.

The scaling breakpoint for scheduled jobs (pg_cron, Supabase Cron) is roughly 200k DAU with 50 signals per user per day — 10M events daily. A scheduled aggregation job completing in under 30 minutes is feasible at this scale. At 1M DAU, the job window starts overlapping with the next run, and users active in the last two hours have stale features. That is when stream processing becomes mandatory — Redpanda with RisingWave, Flink, or a managed option like Materialize — and Supabase transitions from "the database" to "the serving store that receives pre-aggregated feature writes from the stream processor."

3. No day-zero personalization infrastructure

New users have no interaction history. The default Supabase approach — serve them a global popularity ranking or a static onboarding selection — is a significant missed opportunity if any day-zero signals exist: referral source, device type, UTM parameters, declared interests at signup, or social graph connections. Day-zero personalization requires a mechanism to map sparse onboarding signals to a useful initial user representation — typically by assigning new users to a pre-computed synthetic clone or archetype that matches their observable attributes (Wikipedia: Collaborative Filtering).

Supabase gives you the storage to hold archetype centroid vectors and the query primitives to retrieve the nearest one at signup. It does not provide the clustering pipeline to generate archetypes, the signal-collection infrastructure to reliably capture day-zero attributes across platforms, or the feedback loop to measure and improve archetype assignment quality over time. This gap is the most consequential for consumer products where day-zero retention is a primary metric — which is most consumer products. It is also the most tractable to solve without leaving Supabase: run k-means offline on your existing user population, store centroids in a Postgres table, and assign new users via a simple nearest-centroid lookup at signup using observable onboarding signals.

What to bolt on as you scale

If Supabase is the core of your stack, the additions that most commonly complete the personalization layer are:

Stream processor for real-time signal aggregation. Redpanda (Kafka-compatible, operationally simpler) paired with RisingWave (materialized views over streams, with direct Postgres write-back) lets you compute windowed features continuously and push results into Supabase Postgres tables that your serving layer reads from. The personalization code never changes; it just reads from a feature table that is now kept fresh by a stream processor instead of a batch job.

Dedicated vector store for large catalogs. For item catalogs above 5M vectors or applications above 10k similarity QPS, route ANN retrieval to Qdrant or Weaviate and use Supabase exclusively for user data, item metadata, and transactional operations. These two stores are complementary, not competing: Supabase for consistency and user state, a vector store for throughput-sensitive retrieval.

Offline archetype pipeline for cold-start. A weekly k-means clustering job over your active user population (runnable on a $10 modal.com serverless job or a Colab notebook at small scale) produces centroid vectors you import into a Postgres table. A single nearest-centroid query at user signup, using a handful of observable attributes, gives you day-zero personalization that improves measurably over global popularity — without adding any runtime infrastructure dependency.

Where to start

If you are starting a personalization system on Supabase today, this sequence avoids the common dead ends:

Week 1. Nail the signal schema from the start. A user_events table with columns (user_id uuid, item_id text, event_type text, weight float4, created_at timestamptz) covers most interaction patterns. Add a user_preference_vectors table with a vector(N) column matching your embedding model's output dimension and an HNSW index (m=16, ef_construction=64 as defaults). Enable RLS on both tables immediately — retrofitting RLS onto a table with tens of millions of existing rows is painful and requires careful index work to avoid full-table scans during policy evaluation.

Week 2–4. Build the embedding update pipeline. Items get embedded at write time via an Edge Function or an async job that calls your embedding provider. User preference vectors update incrementally on each interaction: the exponential moving average update rule (new_vec = (1 - α) * old_vec + α * item_vec) is simple to implement in a Postgres trigger and gives you a recency-weighted user representation without storing full interaction history for each vector computation. Start with α between 0.05 and 0.15 depending on how quickly your users' interests should shift.

Month 2. Implement cold-start archetypes for day-zero personalization. Cluster your first cohort of active users by preference vector, store the resulting centroids in Postgres, and at signup assign each new user to the nearest centroid using whatever onboarding signals you collect. Measure day-3 retention for archetype-assigned users versus the population-average baseline. The lift is usually significant enough to justify the investment immediately.

Month 3–6. Watch for the stream processing signal. The indicator is not scale in the abstract — it is specifically when your batch aggregation job takes more than 30 minutes to complete, or when you observe that heavy users' feeds fail to reflect interactions from the last two hours. That is the moment to introduce a stream processor. Do not introduce it earlier; the operational complexity is real and premature stream infrastructure is one of the more common over-engineering traps in personalization systems.

The Supabase personalization stack is not a proof-of-concept toy. It ships real recommendation surfaces, real content feeds, and real onboarding personalization at companies with hundreds of thousands of daily active users. The ceiling exists, but it is above where most teams assume it is. The more common failure mode is treating the gaps — cold-start, stream processing, feature versioning — as Supabase's fault rather than as standard infrastructure problems that any personalization system needs to solve, regardless of the database layer underneath.

the product behind these notes

×marble is the personalization graph.

One API. A living knowledge graph per user. Day-zero ready, explainable by construction. We built it so you don't have to.

See ×marble