System Design ·

Designing a Two-Sided Marketplace: Search, Matching, Trust Scores, and Payment Escrow at Scale

Two-sided marketplaces fail at the architecture layer long before they fail at product-market fit. This guide covers the matching problem, search ranking, trust and reputation systems, payment escrow, dispute resolution, and how to handle cold start without lying to your users.

Designing a Two-Sided Marketplace: Search, Matching, Trust Scores, and Payment Escrow at Scale

Most two-sided marketplace articles treat the hard parts as product problems. They are not. The reason Airbnb has a trust score, the reason Etsy can handle 90M listings with relevance-ranked results, and the reason Upwork can hold money for 14 days without becoming a bank is that someone solved the systems architecture problems underneath. This article covers those problems directly.

We will walk through five layers: matching, search ranking, trust and reputation, payment escrow, and cold start. Each section includes concrete TypeScript implementations and honest notes about where things break in production.

The Matching Problem

At its core, a two-sided marketplace connects supply (sellers, providers, drivers) to demand (buyers, clients, riders). The naive approach is to show all available supply and let demand pick. That works until your catalog has more than a few hundred items, or until supply is scarce and you need to allocate efficiently.

The matching problem has two modes: search-driven (buyer browses, picks) and algorithmic (platform pairs them). Most marketplaces need both. Search-driven works for asynchronous high-consideration purchases (a custom logo, a vacation rental). Algorithmic matching works when latency matters (ride dispatch, job bidding, live availability).

For algorithmic matching, you need a scoring function that factors in proximity, availability, quality, and price simultaneously:

interface SupplierCandidate {
  id: string;
  availableAt: Date;
  distanceKm: number;
  qualityScore: number; // 0-1, derived from trust system
  priceIndex: number;   // relative to category median, 1.0 = median
}

interface MatchWeights {
  distance: number;
  quality: number;
  price: number;
  availability: number;
}

function scoreCandidate(
  candidate: SupplierCandidate,
  requestedAt: Date,
  weights: MatchWeights
): number {
  const availabilityDelay =
    (candidate.availableAt.getTime() - requestedAt.getTime()) / 1000 / 60; // minutes

  const distanceScore = Math.exp(-candidate.distanceKm / 10); // decay over 10km
  const availabilityScore = Math.exp(-availabilityDelay / 30); // decay over 30 min
  const priceScore = 1 / candidate.priceIndex; // prefer lower relative price

  return (
    weights.distance * distanceScore +
    weights.quality * candidate.qualityScore +
    weights.price * priceScore +
    weights.availability * availabilityScore
  );
}

The decay functions matter. Linear distance penalties punish the second-closest candidate too harshly; exponential decay reflects how users actually tolerate distance. Calibrate the decay constants from your own data, not intuition.

The weights are category-specific. In ride-hailing, distance and availability dominate. In freelance work, quality and price dominate. Store weights per category and expose them as config so you can tune without a deploy.

Search Ranking

Search ranking in a marketplace is harder than text retrieval because you are simultaneously optimizing for buyer satisfaction, seller fairness, and platform revenue. These objectives conflict.

The input to your ranker includes text relevance, quality signals, freshness, and business rules. A practical architecture separates these into two phases: candidate retrieval and re-ranking.

Candidate retrieval uses an inverted index (Elasticsearch or a similar engine) to get the top-K results by text relevance. Re-ranking applies learned signals to reorder those candidates before returning them to the user.

interface ListingDocument {
  id: string;
  title: string;
  description: string;
  category: string;
  tags: string[];
  priceUsd: number;
  sellerId: string;
  createdAt: Date;
  updatedAt: Date;
}

interface RankingFeatures {
  textScore: number;       // from retrieval (BM25 or similar)
  trustScore: number;      // seller trust, 0-1
  conversionRate: number;  // historical clicks-to-purchase for this listing
  recencyBoost: number;    // listing freshness
  priceCompetitiveness: number; // relative to category median
  completenessScore: number;    // how complete the listing fields are
}

function computeRecencyBoost(updatedAt: Date, now: Date): number {
  const ageHours = (now.getTime() - updatedAt.getTime()) / 1000 / 3600;
  // Full boost for listings updated within 24h, decaying over 30 days
  return Math.max(0, 1 - ageHours / (30 * 24));
}

function rerankListing(features: RankingFeatures, weights: Record<keyof RankingFeatures, number>): number {
  return Object.entries(features).reduce(
    (score, [key, value]) => score + (weights[key as keyof RankingFeatures] ?? 0) * value,
    0
  );
}

The conversion rate feature is the most powerful and the most dangerous. If you rank by historical conversion, you amplify whatever biases existed in your past traffic. New sellers who are never shown to buyers will have zero conversion and will never be shown to buyers. This is the rich-get-richer problem, and it kills marketplace liquidity.

The standard fix is exploration budget: allocate some percentage of impressions (typically 5-15%) to candidates ranked by quality alone, ignoring conversion history. This is the same epsilon-greedy exploration from multi-armed bandit algorithms applied to search.

Trust and Reputation Systems

Trust is the central mechanism that makes a marketplace function. Without it, buyers assume the worst about sellers, sellers price in the risk of non-payment, and transaction volume collapses.

A trust score aggregates multiple signals: explicit ratings, review text sentiment, response rate, dispute history, identity verification level, and time on platform. The aggregation is not a simple average.

interface TrustSignals {
  averageRating: number;        // 1-5
  ratingCount: number;
  disputeRate: number;          // disputes / transactions
  responseRatePercent: number;  // 0-100
  identityVerified: boolean;
  paymentMethodVerified: boolean;
  accountAgeMonths: number;
  fraudFlags: number;           // automated flag count
}

function computeTrustScore(signals: TrustSignals): number {
  // Bayesian-adjusted rating: pull toward global mean (3.5) when sample is small
  const globalMeanRating = 3.5;
  const priorWeight = 10; // equivalent to 10 prior ratings at the global mean
  const adjustedRating =
    (globalMeanRating * priorWeight + signals.averageRating * signals.ratingCount) /
    (priorWeight + signals.ratingCount);
  const ratingScore = (adjustedRating - 1) / 4; // normalize to 0-1

  const disputePenalty = Math.min(signals.disputeRate * 5, 1); // heavy penalty
  const responseScore = signals.responseRatePercent / 100;
  const verificationBonus =
    (signals.identityVerified ? 0.1 : 0) +
    (signals.paymentMethodVerified ? 0.05 : 0);
  const tenureScore = Math.min(signals.accountAgeMonths / 24, 1); // caps at 2 years
  const fraudPenalty = Math.min(signals.fraudFlags * 0.2, 1);

  const raw =
    0.35 * ratingScore +
    0.2 * responseScore +
    0.15 * tenureScore +
    0.1 * (1 - disputePenalty) +
    verificationBonus -
    fraudPenalty;

  return Math.max(0, Math.min(1, raw));
}

The Bayesian adjustment for ratings is non-negotiable. Without it, a seller with two 5-star reviews ranks above a seller with 500 reviews averaging 4.7. The prior weight (10 in the example) is a tunable hyperparameter; calibrate it so it has negligible effect once a seller has 30+ reviews.

Eventual consistency in trust scores is a real production issue. Ratings come in asynchronously. A fraudulent actor might complete 10 transactions quickly to build a score before committing a larger fraud. Two mitigations: first, recompute trust scores on every write to the ratings table and publish the update to a cache; second, cap the rate at which trust score can increase (no more than 0.05 points per day, for example). The cap slows down legitimate sellers slightly but makes trust farming economically unviable.

Payment Escrow and Dispute Resolution

Escrow is how you hold money on behalf of both parties until a transaction condition is satisfied. You are not a bank, so you do not hold the money directly. You instruct a payment processor (Stripe, Adyen) to delay the payout to the seller until release conditions are met.

type EscrowStatus =
  | "pending"
  | "funded"
  | "released"
  | "disputed"
  | "refunded"
  | "expired";

interface EscrowTransaction {
  id: string;
  buyerId: string;
  sellerId: string;
  amountCents: number;
  currency: string;
  paymentIntentId: string;   // Stripe PaymentIntent
  transferGroupId: string;   // Stripe Transfer Group
  status: EscrowStatus;
  fundedAt?: Date;
  releaseAfter: Date;        // auto-release if no dispute by this date
  releasedAt?: Date;
  disputeOpenedAt?: Date;
  disputeResolvedAt?: Date;
  disputeOutcome?: "buyer_wins" | "seller_wins" | "split";
}

async function releaseEscrow(
  escrow: EscrowTransaction,
  stripeClient: Stripe
): Promise<void> {
  if (escrow.status !== "funded") {
    throw new Error(`Cannot release escrow in status: ${escrow.status}`);
  }

  // Transfer from platform account to seller's connected account
  await stripeClient.transfers.create({
    amount: escrow.amountCents,
    currency: escrow.currency,
    destination: escrow.sellerId, // Stripe Connect account ID
    transfer_group: escrow.transferGroupId,
  });

  await db.escrowTransactions.update(escrow.id, {
    status: "released",
    releasedAt: new Date(),
  });
}

The timing strategy for auto-release is one of the most consequential decisions in your payment architecture:

  • Release on delivery confirmation: highest buyer protection, lowest seller cash flow. Good for high-value, high-dispute categories.
  • Release after N days with no dispute: balanced, works for most categories. 3-7 days is typical.
  • Release immediately, hold dispute reserve: best seller experience, requires you to be able to claw back funds if a dispute is filed. Clawbacks are painful.

Dispute resolution needs its own state machine. Keep it separate from the escrow state machine but linked by a foreign key. Disputes involve evidence collection, a review window, and a final ruling. Build the ruling logic as an explicit transition, not an implicit side effect of some other operation. You will need an audit log for every state transition when a seller disputes your dispute ruling.

The Cold Start Problem

A marketplace with no supply has no buyers. A marketplace with no buyers has no supply. This is the cold start problem, and every successful marketplace solved it by cheating, at least initially.

The two standard cheats are: (1) seed supply manually before opening demand, and (2) use the platform operators themselves as initial supply.

From a systems perspective, cold start manifests as empty search results and low match scores. You need to handle this explicitly rather than surfacing an empty page.

interface SearchResult {
  listings: ListingDocument[];
  total: number;
  searchId: string;
  isColdStart: boolean;
}

async function searchWithFallback(
  query: string,
  category: string,
  location: GeoPoint
): Promise<SearchResult> {
  const primary = await searchIndex.query({
    text: query,
    category,
    location,
    radiusKm: 25,
  });

  if (primary.total >= 10) {
    return { ...primary, isColdStart: false };
  }

  // Expand radius when supply is thin
  const expanded = await searchIndex.query({
    text: query,
    category,
    location,
    radiusKm: 100,
  });

  if (expanded.total >= 5) {
    return { ...expanded, isColdStart: true };
  }

  // Fall back to category-only, no location filter
  const categoryFallback = await searchIndex.query({
    text: query,
    category,
    location: null,
  });

  return { ...categoryFallback, isColdStart: true };
}

The isColdStart flag lets your frontend render appropriate messaging (“We found fewer results near you, showing providers in a wider area”) rather than a confusing sparse grid. Be honest with users about what they are looking at.

For trust scores during cold start, new sellers have no ratings. Giving them a zero trust score means they never appear in results, which means they never get ratings. The fix is a reasonable prior: treat a new seller as having a trust score of 0.5 (neutral) until they have at least 5 completed transactions. Display trust scores as “New seller” rather than a numerical score until the prior is washed out.

Tradeoffs at a Glance

LayerSimple ApproachRobust ApproachWhen to Upgrade
MatchingShow all supply, let buyer pickScored ranking with category-specific weightsWhen conversion rate on first page drops below 20%
SearchFull-text search with no re-rankingTwo-phase retrieval + learned re-ranking with exploration budgetWhen top sellers monopolize first page results
TrustAverage star ratingBayesian-adjusted score with rate cap and fraud penaltyWhen you see first fraudulent trust-farming attempt
EscrowRelease on delivery confirmationAuto-release after N days with dispute windowWhen seller cash flow complaints exceed 5% of support volume
Cold startShow empty resultsRadius expansion with fallback and honest messagingDay one

Production Considerations

Consistency in trust scores: Write trust score updates synchronously to your primary database but publish to a read cache asynchronously. Reads during search come from the cache. This means a seller’s trust score in search results can be up to a few minutes stale. That is acceptable. What is not acceptable is dropping a trust score update because your cache write failed. Use a message queue (SQS, Kafka) as the intermediary, not a direct cache write.

Escrow and idempotency: Every escrow state transition must be idempotent. Payment webhooks from Stripe can arrive more than once. If your releaseEscrow function fires twice, you should not transfer money twice. Use the transfer_group as an idempotency key and check for an existing transfer before creating a new one.

Search index freshness: Listing updates (price changes, availability toggles) need to propagate to your search index within seconds, not minutes. Use a CDC (change data capture) pattern to stream listings table writes to your search index via Debezium or similar. Batch indexing on a schedule is not fast enough for a live marketplace.

Marketplace liquidity as a metric: Track the percentage of searches that result in at least one transaction within 24 hours. This is your liquidity metric. A search that returns results but produces no transaction is either a relevance failure or a supply quality failure. Segment this metric by category and location to find where you are thin.

Closing

Two-sided marketplace architecture is not exotic. The primitives are scoring functions, state machines, search indices, and payment processor APIs. What makes it hard is that these systems interact: a thin trust score degrades search quality, which reduces demand, which reduces supply, which increases cold start surface. You have to instrument the feedback loops before you optimize any individual component in isolation, or you will fix the wrong thing.

The architecture layers are: matching (candidate scoring and allocation) > search (retrieval and re-ranking) > trust (aggregated signals with fraud resistance) > escrow (state machine with processor integration) > cold start (graceful degradation with honest UX). Get them in that order and each layer will have the data it needs from the one below it.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.