The Feed System: How We Built a Product Feed That Actually Sells
Most product feeds are dumb lists. Ours has 5 ranking strategies, built-in A/B testing, confidence decay, and deterministic user bucketing. Here's exactly how it works.
•7 min readEvery e-commerce site has a product feed. Most are just "newest first" or "best sellers." Ours is different.
The Feed is Litestore's stored product ranking system. It pre-computes which products should appear in what order, stores those decisions, and serves them instantly. No real-time sorting. No expensive queries. Just reads.
But the interesting part isn't the caching. It's the five different strategies you can choose, the built-in A/B testing, and the confidence decay that prevents stale experiments from corrupting your data.
Why Store the Feed?
Traditional approach:
// Every page load
const products = await db.product.findMany({
where: { status: "ACTIVE" },
orderBy: { createdAt: "desc" },
take: 20,
});
This works until it doesn't. The query gets expensive. The sort becomes unpredictable. You add a score column. Now you need to recompute scores. Every. Single. Request.
Our approach:
// Once per refresh cycle (cron)
const feed = computeFeed(products, strategy, config);
await storeFeed(feed);
// Every page load
const products = await getStoredFeed(page, limit); // Just a read
The feed is a materialized view of your merchandising decisions. It captures:
- Which products appear
- In what order
- Why (explainable ranking)
- For which user segment (A/B variants)
The Five Strategies
1. DETERMINISTIC
The default. Products are scored, sorted, and stored in exact order.
strategy: "DETERMINISTIC";
// Product A (score 8.5) always appears before Product B (score 7.2)
When to use: Homepage hero section, curationd curations, anywhere consistency matters.
Real scenario: A luxury sourcing wants their $2,000 handbag to always appear first in the "Featured" section, regardless of who's viewing. They set a high manual boost, and DETERMINISTIC ensures that order is locked.
2. WEIGHTED_RANDOM
Products are shuffled, but higher-scored products have higher probability of appearing near the top.
strategy: "WEIGHTED_RANDOM",
config: {
topN: 50, // Only shuffle the top 50 ranking products
refreshInterval: 4 // Reshuffle every 4 hours
}
When to use: Discovery feeds, "You might like" sections, anywhere staleness is worse than perfect ordering.
Real scenario: A fashion retailer has 200 products ranking above 7.0. With DETERMINISTIC, the same 20 products appear on page 1 forever. With WEIGHTED_RANDOM, high-ranking products rotate through page 1, giving more inventory exposure while respecting quality signals.
3. SLOT_BASED
Divide the feed into slots with different sourcing rules.
strategy: "SLOT_BASED",
config: {
slots: [
{ positions: [0, 1, 2], source: "curation:new-arrivals" },
{ positions: [3, 4, 5, 6, 7], source: "ranking:top" },
{ positions: [8, 9], source: "curation:sale" }
]
}
When to use: Mixed merchandising where different product categories need guaranteed visibility.
Real scenario: A home goods store wants their feed to show: 3 new arrivals (fresh inventory), 5 best sellers (proven performers), 2 sale items (clear inventory). SLOT_BASED guarantees this mix regardless of individual product scores.
4. SHUFFLE_TOP
Keep the overall order but randomize within the top N positions.
strategy: "SHUFFLE_TOP",
config: {
shuffleCount: 10, // Only shuffle positions 0-9
preserveBelow: true // Positions 10+ stay in score order
}
When to use: When you want variety at the top without disrupting the long tail.
Real scenario: An electronics store's top 10 products are all similar (iPhone cases). SHUFFLE_TOP rotates which cases appear in positions 1-3, preventing "same products every visit" fatigue while keeping the score-based tail intact.
5. MANUAL
Operator-defined order. Scores are ignored.
strategy: "MANUAL",
config: {
productIds: ["prod_abc", "prod_def", "prod_ghi", ...],
fillWithRanking: true // After manual products, fill with scored products
}
When to use: Editorial curation, seasonal campaigns, sourcing partnerships.
Real scenario: A beauty sourcing signs a partnership with a celebrity. Marketing wants exactly these 5 products in exactly this order for the next 2 weeks. MANUAL gives them direct control while fillWithRanking ensures the rest of the feed stays dynamic.
Built-In A/B Testing
Here's where it gets interesting. The feed natively supports experimentation.
{
strategy: "WEIGHTED_RANDOM",
variants: [
{ id: "control", allocation: 50, config: { topN: 30 } },
{ id: "wider-pool", allocation: 50, config: { topN: 100 } }
],
confidence: 0.85
}
How It Works
- Traffic Allocation: 50% of users see variant A, 50% see variant B
- Deterministic Bucketing: Same user always sees the same variant (session consistency)
- Confidence Tracking: System tracks which variant is performing better
- No External Tools: No LaunchDarkly, no Optimizely, no third-party billing
Bucketing Is Deterministic
function getUserBucket(userId: string, experimentId: string): string {
const hash = createHash(userId + experimentId);
const bucket = hash % 100;
// User abc123 + experiment xyz789 always equals bucket 47
// If bucket 47 is in variant A's allocation, they always see A
}
Why this matters: A user who sees "wider-pool" on Monday will see "wider-pool" on Friday. Their experience is consistent. Your metrics are clean.
Confidence Decay
Experiments go stale. A test from 3 months ago shouldn't influence today's decisions with the same weight.
const CONFIDENCE_HALF_LIFE_DAYS = 7;
function getDecayedConfidence(confidence: number, daysOld: number): number {
return confidence * Math.pow(0.5, daysOld / CONFIDENCE_HALF_LIFE_DAYS);
}
A 90% confidence experiment loses half its weight after 7 days. After 14 days, it's at 22.5%. After 21 days, 11.25%.
Why this matters: Old experiments don't permanently lock your merchandising. If you forget to clean up a test, its influence naturally fades.
Real-Life Scenarios
Scenario: Black Friday Prep
Challenge: You want to test whether showing more sale items increases conversion, but you can't risk tanking revenue if the test fails.
Solution:
{
strategy: "SLOT_BASED",
variants: [
{
id: "control",
allocation: 80,
config: {
slots: [
{ positions: [0, 1, 2, 3, 4], source: "ranking:top" },
{ positions: [5, 6], source: "curation:sale" }
]
}
},
{
id: "more-sale",
allocation: 20,
config: {
slots: [
{ positions: [0, 1, 2], source: "ranking:top" },
{ positions: [3, 4, 5, 6], source: "curation:sale" }
]
}
}
]
}
80% of traffic sees the safe option. 20% sees more sale items. If "more-sale" wins, you gradually increase allocation. If it tanks, you've only risked 20% of traffic.
Scenario: New Category Launch
Challenge: You're launching a pet supplies category. You need guaranteed visibility without manually editing the feed daily.
Solution:
{
strategy: "SLOT_BASED",
config: {
slots: [
{ positions: [0], source: "curation:pet-supplies-launch" },
{ positions: [1, 2, 3, 4, 5, 6, 7, 8, 9], source: "ranking:top" }
]
}
}
Position 0 is always a pet product. Positions 1-9 are scored normally. When the launch ends, you remove the slot and the feed auto-heals.
Scenario: Fighting Staleness
Challenge: Your homepage shows the same products every day. Customers complain it feels stale.
Solution:
{
strategy: "WEIGHTED_RANDOM",
config: {
topN: 100,
refreshInterval: 6 // New shuffle every 6 hours
}
}
High-quality products still dominate, but they rotate. A product ranking 9.0 might be position 1 in the morning and position 4 in the afternoon. The feed feels fresh without sacrificing quality signals.
Scenario: Editorial Takeover
Challenge: Your content team publishes a sample guide. They want the feed to reflect their curationd picks for 2 weeks.
Solution:
{
strategy: "MANUAL",
config: {
productIds: ["sample-1", "sample-2", "sample-3", ...],
fillWithRanking: true
},
startsAt: "2025-12-01",
endsAt: "2025-12-15"
}
For 2 weeks, the feed shows the sample guide. On December 15, it auto-reverts to the previous strategy. No manual intervention needed.
The Ranking System
Every feed strategy (except MANUAL) relies on product scores. Here's how we compute them:
finalScore = (1 + engagementScore)
× recencyMultiplier
× boostMultiplier
× inventoryMultiplier
× externalIntelligenceMultiplier
Engagement Score
clicks × 1.0 + views × 0.1 + conversions × 5.0 + likes × 0.5
Recency Multiplier
1.0 - (daysSincePublished × 0.02) // Max decay: 50% over ~25 days
Boost Multiplier
manualBoost value set by operators (1.0 = neutral)
Inventory Multiplier
1.0 if in stock
0.1 if out of stock (suppressed but not removed)
External Intelligence Multiplier
aiConfidenceScore from external sources (1.0 = neutral)
Every rank is explainable:
explainRank(product);
// Returns:
// {
// finalRank: 8.72,
// breakdown: {
// engagement: 3.4,
// recency: 0.94,
// boost: 1.2,
// inventory: 1.0,
// intelligence: 1.1
// },
// explanation: "High engagement (340 clicks) + slight boost (1.2x) + positive AI signal"
// }
Implementation Details
Storage
The feed is stored in the StoredFeed table:
model StoredFeed {
id String @id @default(cuid())
key String @unique // "homepage", "curation:summer", etc.
strategy FeedStrategy
config Json
products Json // Array of { id, score, position }
variants Json? // A/B test configuration
confidence Float? // Current experiment confidence
createdAt DateTime @default(now())
expiresAt DateTime?
}
Refresh Cycle
A cron job runs every 30 minutes:
// lib/cron/feed-refresh.ts
export async function refreshFeeds() {
const feeds = await getActiveFeeds();
for (const feed of feeds) {
const products = await computeFeed(feed.strategy, feed.config);
await storeFeed(feed.key, products);
revalidateTag(`feed-${feed.key}`);
}
}
Query Path
// server/web/feed/queries.ts
export async function getFeed(key: string, page: number, limit: number) {
const feed = await db.storedFeed.findUnique({
where: { key },
cacheStrategy: { tags: [`feed-${key}`], revalidate: 1800 },
});
const start = page * limit;
const products = feed.products.slice(start, start + limit);
return hydrateProducts(products); // Fetch full product data for IDs
}
What's Next
The feed system is solid. What we're building next:
- Real-time signals — Incorporate "X people viewing" into ranking
- Personalization layer — Different feeds for different user segments (without fragmenting the base)
- Performance attribution — Track which feed variant drove which conversion
- Auto-strategy selection — AI recommends which strategy fits your catalog best
But the foundation — stored rankings, multiple strategies, native A/B testing, confidence decay — that's done and battle-tested.
If your product feed is just ORDER BY created_at DESC, you're leaving money on the table.
