Command Menu

Litestore
ShopCategoriesCollections
/Blog
/The Feed System: How We Built a Product Feed That Actually Sells

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.

Jan 29, 2025•7 min read

Every 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

  1. Traffic Allocation: 50% of users see variant A, 50% see variant B
  2. Deterministic Bucketing: Same user always sees the same variant (session consistency)
  3. Confidence Tracking: System tracks which variant is performing better
  4. 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:

  1. Real-time signals — Incorporate "X people viewing" into ranking
  2. Personalization layer — Different feeds for different user segments (without fragmenting the base)
  3. Performance attribution — Track which feed variant drove which conversion
  4. 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.

Share:
Written by
Fabian Likam's profile

Fabian Likam

@fabianlikam
Shop
All ProductsNew ArrivalsNewBest SellersSale
Help
Contact UsFAQShippingReturns
Company
About UsBlogCareersPress
Connect
InstagramTwitterTiktokYoutube
Privacy PolicyTerms of Service
  • Visa
  • Mastercard
  • American Express
  • PayPal
  • Apple Pay
  • Google Pay
Secure Checkout

© 2026 Litestore. All rights reserved.

HomeCartWishlistAccount