Back to Resources
Optimize & Scale
Advanced
50 min
Chris MaskChris Mask
May 11, 2025

Scaling Marketplace Infrastructure from 100 to 100K Users

Learn when and how to scale marketplace infrastructure. Includes caching strategies, database optimization, CDN implementation, and cost-performance targets.

Who Is This For?

This guide is specifically designed for:

Startup Stage:

Early Traction

Acquiring first users, generating initial revenue, and proving product-market fit.

Best For Role:

Developers

Technical implementation guides and code examples for developers.

Expected Impact:

Long-term Investment

Foundational work that pays dividends over months and years.

Platform: Next.js / React
Reading Level: Advanced

What You'll Learn

  • Identify performance bottlenecks at each growth stage
  • Implement strategic caching with Redis
  • Configure database read replicas for scaling reads
  • Deploy CDN for global asset delivery
  • Build background job processing systems
  • Set up monitoring and alerting for performance issues
  • Optimize costs while maintaining performance targets

Prerequisites

  • Understanding of web application performance metrics
  • Experience with Redis or similar caching systems
  • Knowledge of database replication concepts
  • Familiarity with CDN and asset optimization

Performance problems kill marketplace momentum. This guide provides a phased approach to scaling infrastructure from 100 users to 100,000+ users without expensive rewrites.

The Scaling Curve: What Breaks When

Understanding when to optimize is as important as knowing how to optimize.

100-1,000 Users

Symptoms:

  • Everything works fine
  • Database handles all queries easily
  • No caching needed

Infrastructure:

  • Single PostgreSQL database
  • Next.js on Vercel
  • Basic image storage

Monthly cost: $100-200 Performance target: <500ms page loads Action required: None (focus on product)

1,000-10,000 Users

Symptoms:

  • Homepage loads in >1 second
  • Database queries slowing down (200ms → 2s)
  • Same queries executed hundreds of times per minute
  • Infrastructure costs doubling monthly

Action required: Add strategic caching

Monthly cost: $500-1,000 Performance target: <1s page loads, <50ms cached queries

10,000-50,000 Users

Symptoms:

  • Read queries dominating database load
  • Search becoming slow (500ms-2s)
  • Image loading bottlenecks
  • Background tasks blocking main thread

Action required: Read replicas, dedicated search, CDN

Monthly cost: $2,000-5,000 Performance target: <30ms read queries, <50ms search

50,000-100,000+ Users

Symptoms:

  • Database approaching connection limits
  • Complex queries still slow despite indexes
  • Geographic latency issues
  • High infrastructure costs

Action required: Denormalization, partitioning, microservices

Monthly cost: $5,000-15,000 Performance target: Maintain <100ms for most operations

Key insight: Each optimization buys 10x growth before the next scaling cliff.

Phase 1: 0-1,000 Users (Keep It Simple)

At this stage, do not optimize anything. Focus on product, not performance.

Required Setup

// Simple Next.js app
// app/listings/page.tsx

export default async function ListingsPage() {
  const listings = await db.listing.findMany({
    where: { status: 'active' },
    include: { seller: true, category: true },
    orderBy: { createdAt: 'desc' },
    take: 50,
  })

  return <ListingsGrid listings={listings} />
}

The Only Optimizations That Matter

1. Database Indexes on Foreign Keys

CREATE INDEX idx_listings_seller ON listings(seller_id);
CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_status ON listings(status) WHERE status = 'active';

2. Image Optimization

// Use Next.js Image component
import Image from 'next/image'

<Image
  src={listing.primaryImageUrl}
  width={400}
  height={300}
  alt={listing.title}
  loading="lazy"
/>

3. Basic Pagination

// app/listings/page.tsx
export default async function ListingsPage({
  searchParams,
}: {
  searchParams: { page?: string }
}) {
  const page = parseInt(searchParams.page || '1')
  const perPage = 50

  const [listings, total] = await Promise.all([
    db.listing.findMany({
      where: { status: 'active' },
      skip: (page - 1) * perPage,
      take: perPage,
      orderBy: { createdAt: 'desc' },
    }),
    db.listing.count({
      where: { status: 'active' },
    }),
  ])

  return (
    <div>
      <ListingsGrid listings={listings} />
      <Pagination currentPage={page} totalPages={Math.ceil(total / perPage)} />
    </div>
  )
}

What NOT to Do

  • Add Redis (you don't need it yet)
  • Implement complex caching (premature optimization)
  • Worry about CDN (Vercel Edge is sufficient)
  • Build microservices (monolith is fine)

Phase 2: 1,000-10,000 Users (Strategic Caching)

Performance becomes a growth blocker. Users notice slow pages and bounce.

Symptom Checklist

Implement caching if you see 2+ of these:

  • Homepage takes >1 second to load
  • Listing detail pages query database on every view
  • Same queries run hundreds of times per minute
  • Database/infrastructure bill doubled last month

Solution 1: Add Redis for Hot Data

// lib/cache.ts
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

export async function getCached<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600,
): Promise<T> {
  // Try cache first
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }

  // Cache miss, fetch data
  const data = await fetcher();

  // Store in cache
  await redis.setex(key, ttl, JSON.stringify(data));

  return data;
}

export async function invalidateCache(pattern: string) {
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

Implementation:

// app/listings/[slug]/page.tsx
export default async function ListingDetailPage({
  params,
}: {
  params: { slug: string }
}) {
  const listing = await getCached(
    `listing:${params.slug}`,
    async () => {
      return await db.listing.findUnique({
        where: { slug: params.slug },
        include: {
          seller: true,
          category: true,
          reviews: {
            take: 10,
            orderBy: { createdAt: 'desc' },
          },
        },
      })
    },
    3600 // Cache for 1 hour
  )

  return <ListingDetail listing={listing} />
}

// Invalidate when listing is updated
async function updateListing(id: string, data: any) {
  const listing = await db.listing.update({
    where: { id },
    data,
  })

  // Invalidate cache
  await invalidateCache(`listing:${listing.slug}`)

  return listing
}

Caching Strategy:

Data TypeTTLInvalidation
Listing details1 hourOn update
User profiles30 minOn profile edit
Category lists24 hoursOn category add/edit
Homepage featured15 minTime-based

What NOT to cache:

  • Cart/checkout data (always fresh)
  • User-specific data (use session storage)
  • Real-time counts (use approximate counts)

Solution 2: Database Connection Pooling

// lib/db.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const db =
  globalForPrisma.prisma ||
  new PrismaClient({
    log:
      process.env.NODE_ENV === "development"
        ? ["query", "error", "warn"]
        : ["error"],
    datasources: {
      db: {
        url: process.env.DATABASE_URL,
      },
    },
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

// Add PgBouncer for connection pooling
// DATABASE_URL=postgresql://user:pass@host:6543/db?pgbouncer=true

Impact:

  • 10x more concurrent connections
  • 50% reduction in connection overhead
  • Cost: $0 (PgBouncer is free)

Solution 3: Optimize Expensive Queries

Before (N+1 problem):

// 1 + 50 queries
const listings = await db.listing.findMany({ take: 50 });

for (const listing of listings) {
  listing.seller = await db.user.findUnique({
    where: { id: listing.sellerId },
  });
}

After (Single query with join):

// 1 query
const listings = await db.listing.findMany({
  take: 50,
  include: {
    seller: {
      select: {
        id: true,
        firstName: true,
        lastName: true,
        avatarUrl: true,
        sellerRating: true,
      },
    },
  },
});

Impact: 50 queries → 1 query, 500ms → 50ms

Solution 4: Background Jobs for Heavy Operations

// workers/image-processor.ts
import { Queue, Worker } from "bullmq";
import sharp from "sharp";

const imageQueue = new Queue("image-processing", {
  connection: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT || "6379"),
  },
});

// Add job when image is uploaded
export async function processListingImages(
  listingId: string,
  imageUrls: string[],
) {
  await imageQueue.add("process-images", {
    listingId,
    imageUrls,
  });
}

// Worker processes jobs in background
new Worker(
  "image-processing",
  async (job) => {
    const { listingId, imageUrls } = job.data;

    const processedImages = await Promise.all(
      imageUrls.map(async (url) => {
        const response = await fetch(url);
        const buffer = Buffer.from(await response.arrayBuffer());

        // Generate sizes
        const [thumbnail, medium, large] = await Promise.all([
          sharp(buffer).resize(200, 200).webp({ quality: 80 }).toBuffer(),
          sharp(buffer).resize(800, 800).webp({ quality: 85 }).toBuffer(),
          sharp(buffer).resize(1600, 1600).webp({ quality: 90 }).toBuffer(),
        ]);

        // Upload to storage and return URLs
        return await uploadImages(thumbnail, medium, large);
      }),
    );

    // Update listing with processed images
    await db.listing.update({
      where: { id: listingId },
      data: {
        images: processedImages,
        primaryImageUrl: processedImages[0].large,
      },
    });
  },
  {
    connection: {
      host: process.env.REDIS_HOST,
      port: parseInt(process.env.REDIS_PORT || "6379"),
    },
  },
);

Infrastructure additions:

  • Upstash Redis: $10-40/month
  • Background worker on Railway: $20/month
  • BullMQ for job queue

Performance gain: 50% faster page loads, 80% faster image processing

Database read operations become the bottleneck. Writes are fine, but reads are slow.

Solution 1: Add Read Replicas

// lib/db.ts
import { PrismaClient } from "@prisma/client";

// Primary database (for writes)
export const dbPrimary = new PrismaClient({
  datasources: {
    db: { url: process.env.DATABASE_URL },
  },
});

// Read replica (for queries)
export const dbReplica = new PrismaClient({
  datasources: {
    db: { url: process.env.DATABASE_REPLICA_URL },
  },
});

// Helper functions
export async function query<T>(
  fn: (db: PrismaClient) => Promise<T>,
): Promise<T> {
  return await fn(dbReplica);
}

export async function mutate<T>(
  fn: (db: PrismaClient) => Promise<T>,
): Promise<T> {
  return await fn(dbPrimary);
}

Usage:

// Read from replica
const listings = await query((db) =>
  db.listing.findMany({
    where: { status: "active" },
    include: { seller: true },
  }),
);

// Write to primary
const newListing = await mutate((db) =>
  db.listing.create({
    data: listingData,
  }),
);

Setup:

  • Railway/Supabase: Enable read replicas ($100-200/month)
  • Route 90% of queries to replica
  • Keep writes on primary

Impact:

  • 5x read capacity
  • 70% reduction in primary database load
  • <50ms for most queries

Solution 2: Dedicated Search (Typesense/Algolia)

PostgreSQL full-text search struggles at 10K+ listings.

Migrate to Typesense:

// lib/search.ts
import Typesense from "typesense";

const client = new Typesense.Client({
  nodes: [
    {
      host: process.env.TYPESENSE_HOST!,
      port: 443,
      protocol: "https",
    },
  ],
  apiKey: process.env.TYPESENSE_API_KEY!,
});

// Index listings
export async function indexListing(listing: Listing) {
  await client
    .collections("listings")
    .documents()
    .upsert({
      id: listing.id,
      title: listing.title,
      description: listing.description,
      price: listing.priceCents,
      category: listing.category.name,
      seller: listing.seller.name,
      location: listing.locationCity,
      created_at: listing.createdAt.getTime() / 1000,
    });
}

// Search
export async function searchListings(query: string, filters: any = {}) {
  const results = await client
    .collections("listings")
    .documents()
    .search({
      q: query,
      query_by: "title,description,category",
      filter_by: buildFilterString(filters),
      sort_by: "created_at:desc",
      per_page: 50,
    });

  return results.hits?.map((hit) => hit.document);
}

function buildFilterString(filters: {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  location?: string;
}) {
  const conditions: string[] = [];

  if (filters.category) {
    conditions.push(`category:=${filters.category}`);
  }

  if (filters.minPrice && filters.maxPrice) {
    conditions.push(`price:${filters.minPrice}..${filters.maxPrice}`);
  }

  if (filters.location) {
    conditions.push(`location:=${filters.location}`);
  }

  return conditions.join(" && ");
}

Cost comparison:

  • Self-hosted Typesense: $0-50/month (Railway)
  • Algolia: $1-500/month (based on searches)

Performance:

  • PostgreSQL: 500ms-2s for complex queries
  • Typesense: <50ms for same queries
  • Support for typos, synonyms, faceted search

Solution 3: CDN for All Assets

// next.config.js
module.exports = {
  images: {
    domains: ["your-cdn.com"],
    loader: "custom",
    loaderFile: "./lib/image-loader.ts",
  },
};

// lib/image-loader.ts
export default function cloudflareLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  const params = [`width=${width}`];
  if (quality) {
    params.push(`quality=${quality}`);
  }
  return `https://your-cdn.com/cdn-cgi/image/${params.join(",")}/${src}`;
}

Cloudflare R2 + CDN setup:

  • $5/month for storage
  • $0.36 per million requests
  • Global edge caching

Impact:

  • Image load time: 1s → 100ms
  • Bandwidth costs: 80% reduction
  • Global performance improvement

Phase 4: 50,000-100,000+ Users (Advanced Optimizations)

Solution 1: Denormalize Hot Data

Before (3 table joins):

const listing = await db.listing.findUnique({
  where: { id },
  include: {
    seller: true,
    category: true,
    reviews: { take: 10 },
  },
});

After (cached aggregates):

ALTER TABLE listings
ADD COLUMN seller_name VARCHAR(255),
ADD COLUMN seller_rating DECIMAL(3,2),
ADD COLUMN category_name VARCHAR(100),
ADD COLUMN review_count INTEGER DEFAULT 0,
ADD COLUMN average_rating DECIMAL(3,2);

-- Trigger to update on changes
CREATE OR REPLACE FUNCTION update_listing_cache()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE listings SET
    seller_name = (SELECT CONCAT(first_name, ' ', last_name) FROM users WHERE id = NEW.seller_id),
    seller_rating = (SELECT seller_rating FROM users WHERE id = NEW.seller_id),
    category_name = (SELECT name FROM categories WHERE id = NEW.category_id)
  WHERE id = NEW.id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Impact:

  • 3 queries → 1 query
  • 100ms → 10ms

Solution 2: Implement Rate Limiting

// lib/rate-limit.ts
import { Redis } from "ioredis";
import { headers } from "next/headers";

const redis = new Redis(process.env.REDIS_URL);

export async function rateLimit(
  identifier: string,
  limit: number = 100,
  window: number = 60,
): Promise<{ success: boolean; remaining: number }> {
  const key = `rate-limit:${identifier}`;

  const current = await redis.incr(key);

  if (current === 1) {
    await redis.expire(key, window);
  }

  const remaining = Math.max(0, limit - current);

  return {
    success: current <= limit,
    remaining,
  };
}

// Use in API routes
export async function POST(request: Request) {
  const ip = headers().get("x-forwarded-for") || "unknown";

  const { success, remaining } = await rateLimit(ip, 10, 60);

  if (!success) {
    return Response.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "X-RateLimit-Remaining": remaining.toString(),
        },
      },
    );
  }

  // Process request...
}

Solution 3: Database Partitioning

-- Partition transactions by month
CREATE TABLE transactions (
  id UUID,
  created_at TIMESTAMPTZ,
  -- other columns...
) PARTITION BY RANGE (created_at);

CREATE TABLE transactions_2025_01 PARTITION OF transactions
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE transactions_2025_02 PARTITION OF transactions
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Automatically drop old partitions
DROP TABLE transactions_2024_01;

Impact:

  • Faster queries (only scan relevant partition)
  • Easier data archival
  • Better index performance

Monitoring: Know Before It Breaks

// lib/monitoring.ts
import * as Sentry from "@sentry/nextjs";

export function trackPerformance(operation: string, duration: number) {
  Sentry.metrics.distribution("operation.duration", duration, {
    tags: { operation },
    unit: "millisecond",
  });

  // Alert if operation is slow
  if (duration > 1000) {
    Sentry.captureMessage(
      `Slow operation: ${operation} took ${duration}ms`,
      "warning",
    );
  }
}

// Usage
const start = Date.now();
const listings = await db.listing.findMany();
trackPerformance("fetch-listings", Date.now() - start);

Monitoring stack:

  • Sentry: Error tracking + performance
  • Vercel Analytics: Web vitals
  • Railway Metrics: Database/infrastructure
  • Upstash: Redis monitoring

Alert on:

  • Page load >3s
  • API response >1s
  • Error rate >1%
  • Database connections >80% pool

Scaling Summary Table

UsersOptimizationMonthly CostTime to ImplementPerformance Target
100-1KNothing (focus on product)$100-200-<500ms
1K-10KRedis caching + indexes$500-1K1 week<1s, <50ms cached
10K-50KRead replicas + Typesense$2K-5K2 weeks<30ms reads, <50ms search
50K-100KCDN + denormalization$5K-15K2-3 weeks<100ms most ops
100K+Sharding + microservices$15K+4-6 weeksMaintain <100ms

Common Mistakes

1. Optimizing Too Early

Building microservices at 100 users wastes time that should be spent on product development.

2. Optimizing Too Late

Adding caching at 50K users (should've been at 1K) results in emergency performance fixes under user pressure.

3. Optimizing the Wrong Thing

Speeding up admin panel instead of user-facing pages has no impact on growth or retention.

The Scaling Strategy

  1. Measure first: Use Vercel Analytics, Sentry to identify actual bottlenecks
  2. Optimize the slowest user-facing operations: Not admin features
  3. Implement incrementally: One optimization per sprint
  4. Monitor impact: Track before/after metrics

Real-World Example

B2B Service Marketplace Timeline:

  • Month 1: 500 users, $150/month infrastructure
  • Month 6: 5,000 users, added Redis caching, $800/month
  • Month 12: 25,000 users, added read replicas + Typesense, $3,500/month
  • Month 18: 75,000 users, added CDN + denormalization, $8,000/month

Performance maintained:

  • Homepage: <1s load time
  • Search: <100ms
  • Checkout: <2s end-to-end
  • Uptime: 99.95%

Total refactoring needed: Zero major rewrites, only incremental additions

Implementation Roadmap

Phase 1 (Weeks 1-8): Build MVP with basic optimizations Phase 2 (Months 2-6): Add Redis caching when database queries slow down Phase 3 (Months 6-12): Implement read replicas and dedicated search Phase 4 (Months 12-18): Deploy CDN and advanced optimizations Phase 5 (18+ months): Consider sharding and microservices

Next Steps

  1. Performance audit: Measure current page load times and database query performance
  2. Identify bottlenecks: Use monitoring tools to find slowest operations
  3. Plan scaling phase: Determine which phase you're in and what optimization to implement next
  4. Implement monitoring: Set up Sentry and analytics before problems occur
  5. Budget allocation: Plan infrastructure costs for next 12 months

Is your platform ready to scale?

Find the bottlenecks holding your marketplace back. Takes about 3 minutes.

Take the Growth Assessment
#scalability
#performance
#infrastructure
#optimization
#caching
#redis
Found this helpful? Share it
Share:

About the Author

Chris Mask

Chris Mask

Founder & CEO

Serial entrepreneur, marketplace architect, and AI-assisted development pioneer with 7+ years building two-sided platforms. Founded Directorism after launching and exiting two successful marketplace businesses. Has personally architected and consulted on 200+ marketplace and directory projects. Recognized authority on cold-start problems, platform economics, marketplace SEO, and leveraging AI tools for rapid development. Early adopter of AI-powered coding workflows, integrating Claude, Cursor, and agentic development patterns into production systems.