System Design

# Pinterest-Style Search & Pin Creation β€” Design Document

Scope: Pin creation (image + description) and search (text query, suggestions, masonry grid results)
Out of scope: Video uploads (mp4), canvas-based pin editor, boards, social graph, recommendations


# 1. System Overview

# Scale

Anchor: 10M pins, 50K daily active users.

At this scale: Elasticsearch handles full-text search with sub-50ms p99 query latency via Redis caching and a lean index (description + suggest only). Image storage and CDN egress dominate cost β€” WebP variants, immutable UUID URLs, and aggressive CDN caching keep egress near-zero at steady state. CDC (not dual-write) keeps the search index consistent without unbounded polling cost as pin volume grows.

# Stack

  • Frontend: Next.js (React) β€” App Router with React Suspense streaming
  • Backend: Node.js API
  • Primary DB: PostgreSQL
  • Search index: Elasticsearch
  • Object storage: S3 (pin images, presigned upload)
  • CDN: CloudFront / Cloudflare (SSR HTML + image variants)
  • Cache: Redis (60s TTL on search/suggestions)
  • Image processing: Sharp worker (WebP variants, dominant color extraction)
  • Queues: SQS β€” processing queue (S3 upload β†’ Sharp) and index queue (CDC β†’ Elasticsearch)
  • CDC: Debezium (Postgres WAL β†’ index queue)

# Surfaces

Two user-facing surfaces:

Surface Auth Rendering
Pin Creation Required CSR
Search & Results Public Streaming SSR + CSR

System architecture diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          Browser                                 β”‚
β”‚   Next.js App (React Suspense streaming / CSR per route)        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚ REST                     β”‚ presigned URL upload
          β–Ό                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Node.js API    β”‚        β”‚   S3 (images)   │◄── CDN (CloudFront)
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ writes                      β”‚ ObjectCreated event
       β–Ό                             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  PostgreSQL  β”‚           β”‚  SQS (proc queue) β”‚
β”‚  (source of  │◄──────────│                  β”‚
β”‚    truth)    β”‚  update   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
       β”‚ WAL                      β–Ό
       β–Ό                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚ Sharp worker β”‚ (generates variants,
β”‚  CDC process β”‚          β”‚              β”‚  extracts dominant color,
β”‚  (Debezium)  β”‚          β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  updates Postgres)
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
       β”‚                         β”‚
       β–Ό                         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”‚
β”‚ Index queue  β”‚                 β”‚
β”‚ (SQS/Kafka)  β”‚                 β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
       β–Ό                         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
β”‚ Indexing consumerβ”‚             β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
       β–Ό                         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Elasticsearch   β”‚    β”‚  Redis (cache)    β”‚
β”‚  (search index)  β”‚    β”‚  60s TTL          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

# 2. Data Model

# PostgreSQL β€” pins table

CREATE TABLE pins (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  description    TEXT NOT NULL,
  s3_key         TEXT NOT NULL,           -- pins/{id}/original.{ext}
  width          INTEGER NOT NULL,        -- original image dimensions
  height         INTEGER NOT NULL,        -- used for masonry pre-calc
  dominant_color CHAR(7) NOT NULL,        -- e.g. "#a3b4c5"
  status         TEXT NOT NULL            -- uploading | processing | ready
                 DEFAULT 'uploading',
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX pins_status_created_at ON pins (status, created_at DESC);

Image URLs are derived, never stored β€” constructed from s3_key:

https://cdn.example.com/pins/{id}/236w.webp   ← search grid (srcset small)
https://cdn.example.com/pins/{id}/474w.webp   ← search grid (srcset large / retina)
https://cdn.example.com/pins/{id}/736w.webp   ← pin detail page

# Elasticsearch β€” pins index

Only searchable fields are indexed. Full pin data stays in Postgres.

{
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "description": { "type": "text", "analyzer": "english" },
      "created_at": { "type": "date" },
      "suggest": {
        "type": "completion",
        "analyzer": "simple"
      }
    }
  }
}

The suggest field is populated from tokenised description text β€” powers the suggestion API via ES completion suggester.


# 3. API Design (REST)

# Pin Creation

# POST /api/pins/upload-url

Request a presigned S3 URL. Creates the pin row in Postgres immediately with status: uploading.

Request:  { content_type: "image/jpeg", file_size: 8388608 }
Response: { pin_id: "uuid", upload_url: "https://s3.../...", expires_in: 300 }

Validation: content_type must be image/jpeg or image/png. file_size must be ≀ 20MB. Rejected immediately β€” no S3 key issued.

# POST /api/pins

Submit pin metadata after upload completes.

Request:  { pin_id: "uuid", description: "..." }
Response: { pin_id: "uuid", status: "processing" }

S3 ObjectCreated event (not this endpoint) triggers the Sharp processing job.

# GET /api/pins/:id

Returns a single pin for the detail page. Reads from Postgres.


# GET /api/search?q={query}&cursor={cursor}

Returns the first (or next) page of results from Elasticsearch.

{
  "pins": [
    {
      "id": "uuid",
      "description": "Best Things to Do in Flam Norway",
      "width": 1024,
      "height": 1536,
      "dominant_color": "#4a7c59",
      "images": {
        "236w": "https://cdn.example.com/pins/uuid/236w.webp",
        "474w": "https://cdn.example.com/pins/uuid/474w.webp"
      }
    }
  ],
  "next_cursor": "opaque_search_after_token"
}
  • Ranking: BM25 relevance combined with function_score exponential decay on created_at (scale: 30 days).
  • Pagination: search_after β€” no offset. Cursor is the sort values of the last document, base64-encoded.
  • Caching: Redis checks before hitting ES. Key: search:{sha256(q)}:{cursor}. TTL: 60s.

# GET /api/suggestions?q={query}

Returns up to 8 suggestion strings from ES completion suggester.

{
  "suggestions": [
    "keyboards mechanical",
    "keyboards gaming",
    "keyboards aesthetic"
  ]
}

Cached in Redis. Key: suggest:{sha256(q)}. TTL: 60s.


# 4. Pin Creation Flow

Browser                  API                    S3              SQS           Sharp Worker       Postgres
  β”‚                       β”‚                      β”‚               β”‚                β”‚                 β”‚
  β”‚ POST /upload-url       β”‚                      β”‚               β”‚                β”‚                 β”‚
  │──────────────────────►│                      β”‚               β”‚                β”‚                 β”‚
  β”‚                       β”‚ INSERT pin            β”‚               β”‚                β”‚                 β”‚
  β”‚                       β”‚ status=uploading      β”‚               β”‚                β”‚        ─────────►│
  β”‚                       β”‚ generate presigned URLβ”‚               β”‚                β”‚                 β”‚
  │◄──────────────────────│                      β”‚               β”‚                β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚                β”‚                 β”‚
  β”‚ PUT image (binary)    β”‚                      β”‚               β”‚                β”‚                 β”‚
  │──────────────────────────────────────────────►               β”‚                β”‚                 β”‚
  │◄─────────────────────────────────────────────               β”‚                β”‚                 β”‚
  β”‚                       β”‚                 ObjectCreated event  β”‚                β”‚                 β”‚
  β”‚                       β”‚                      │──────────────►│                β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚ enqueue job    β”‚                 β”‚
  β”‚                       β”‚                      β”‚               │───────────────►│                 β”‚
  β”‚ POST /pins (metadata) β”‚                      β”‚               β”‚                β”‚                 β”‚
  │──────────────────────►│                      β”‚               β”‚                β”‚                 β”‚
  │◄──────────────────────│                      β”‚               β”‚                β”‚                 β”‚
  β”‚ status: processing    β”‚                      β”‚               β”‚                β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚  run Sharp     β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚  236w/474w/736wβ”‚                 β”‚
  β”‚                       β”‚                      │◄──────────────────────────────│                 β”‚
  β”‚                       β”‚                      β”‚  store variants               β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚  UPDATE pin    β”‚                 β”‚
  β”‚                       β”‚                      β”‚               β”‚  status=ready  β”‚        ─────────►│
  β”‚                       β”‚                      β”‚               β”‚  +dominant_colorβ”‚                β”‚

After status=ready is written, the CDC process picks up the WAL event and routes it to the indexing consumer, which writes to Elasticsearch. The pin appears in search within seconds.


# 5. Search Flow

Browser (Next.js)              API + Redis              Elasticsearch
  β”‚                                 β”‚                        β”‚
  β”‚  GET /search?q=keyboards        β”‚                        β”‚
  β”‚  (initial page load β€” SSR)      β”‚                        β”‚
  │────────────────────────────────►│                        β”‚
  β”‚                                 β”‚ Redis HIT β†’ return     β”‚
  │◄────────────────────────────────│                        β”‚
  β”‚  HTML shell streamed first      β”‚                        β”‚
  β”‚  Pin data streams inline        β”‚  Redis MISS            β”‚
  β”‚  (React Suspense boundary)      │────────────────────────►│
  β”‚                                 │◄────────────────────────│
  β”‚                                 β”‚ write to Redis         β”‚
  │◄────────────────────────────────│                        β”‚
  β”‚  Hydration: no second fetch     β”‚                        β”‚
  β”‚                                 β”‚                        β”‚
  β”‚  [user scrolls to bottom]       β”‚                        β”‚
  β”‚                                 β”‚                        β”‚
  β”‚  GET /api/search?q=keyboards    β”‚                        β”‚
  β”‚  &cursor=opaque_token  (CSR)    β”‚                        β”‚
  │────────────────────────────────►│                        β”‚
  │◄────────────────────────────────│                        β”‚
  β”‚  Append new pins to grid        β”‚                        β”‚

# 6. Frontend Architecture

# Routing & Rendering

Route Strategy Reason
/search?q=... Streaming SSR (initial) + CSR (scroll) SEO, LCP, shareable URLs
/pin/create CSR Auth-gated, no SEO value, highly interactive
/pin/:id SSR Shareable, crawlable

The Next.js App Router loading.tsx file provides the immediate shell. A <Suspense> boundary wraps the pin grid β€” the server streams pin data into it as the ES query resolves.

Search results wireframe

# Search suggestions

Search autocomplete wireframe

# Pin creation

Pin creation wireframe

# Masonry Grid

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          β”‚  β”‚          β”‚  β”‚          β”‚
β”‚  Pin A   β”‚  β”‚  Pin B   β”‚  β”‚  Pin C   β”‚
β”‚ h=320px  β”‚  β”‚ h=480px  β”‚  β”‚ h=260px  β”‚
β”‚          β”‚  β”‚          β”‚  β”‚          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚          β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚          β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚          β”‚
β”‚  Pin D   β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  Pin E   β”‚
β”‚ h=200px  β”‚  β”‚          β”‚  β”‚ h=400px  β”‚
β”‚          β”‚  β”‚  Pin F   β”‚  β”‚          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ h=360px  β”‚  β”‚          β”‚
              β”‚          β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Layout algorithm:

  1. On mount, read container width β†’ calculate column count (e.g. 2 on mobile, 3 on tablet, 4 on desktop).
  2. Track columnHeights[]. For each pin, place it in the shortest column. Position: { top: columnHeights[col], left: col * (colWidth + gap) }.
  3. Pin height is known from Postgres (stored at upload time) β€” calculated as (stored_height / stored_width) * colWidth. Zero layout shift; no waiting for images to load.
  4. Container height = Math.max(...columnHeights).

Loading placeholder: Each pin slot renders with background-color: dominant_color immediately. The <img> crossfades in on load. On onerror: wait 2s, retry once. If retry fails, show dominant color + broken-image icon. Slot never collapses (CLS violation).

Virtualization: Only pins whose calculated top is within [scrollY - overscan, scrollY + viewportHeight + overscan] are rendered. Overscan = 1 viewport height. Unmounted pins leave their slot height intact (a div with the pre-calculated dimensions), so scroll position is stable.

Image markup (grid card):

<img
  src="https://cdn.example.com/pins/uuid/474w.webp"
  srcset="
    https://cdn.example.com/pins/uuid/236w.webp 236w,
    https://cdn.example.com/pins/uuid/474w.webp 474w
  "
  sizes="(max-width: 600px) 50vw, (max-width: 900px) 33vw, 25vw"
  fetchpriority="high"
  ←
  first
  visible
  row
  only
  loading="lazy"
  ←
  all
  others
  alt="Best Things to Do in Flam Norway"
  width="474"
  height="711"
/>

Paint scheduling:

Trigger Scheduler Reason
New batch loaded (scroll) requestAnimationFrame Frame-critical β€” must land before next paint
Viewport resize requestIdleCallback (after 150ms debounce on ResizeObserver) Non-urgent β€” user not interacting with pins during resize

# Search Bar & Suggestions

  • Input is debounced at 200ms before firing GET /api/suggestions.
  • Each new keystroke calls AbortController.abort() on the previous in-flight request before creating a new one. Prevents stale suggestions from a slow earlier query rendering after a faster later one.
  • Suggestions rendered as a dropdown role="listbox" / role="option" list. Keyboard: ↑/↓ navigate, Enter submits, Escape closes.

# Accessibility

Element Attribute
Grid container role="list"
Each pin card role="listitem", aria-label="{description}"
Each <img> alt="{description}"
Infinite scroll trigger aria-live="polite" region β€” announces "Loading more pins" / "X new pins loaded"
Keyboard tab order DOM insertion order (not overridden to match visual column order)

# 7. Image Processing Pipeline

At upload time, the Sharp worker performs all transforms in a single pass:

  1. Validate β€” reject if not JPEG/PNG, abort if corrupt.
  2. Extract metadata β€” width, height of original.
  3. Extract dominant color β€” quantize to 1 color, store as hex.
  4. Generate WebP variants β€” 236w, 474w, 736w, quality 80.
  5. Write variants to S3 β€” pins/{uuid}/236w.webp etc.
  6. Delete original from S3 β€” cost control.
  7. UPDATE pin β€” set status=ready, dominant_color, width, height.

CDN cache headers on all variant objects:

Cache-Control: public, max-age=31536000, immutable

URLs are UUID-keyed and content never changes β€” safe for permanent caching.


# 8. Elasticsearch Sync (CDC)

Postgres WAL
    β”‚
    β–Ό
CDC process (Debezium)
    β”‚  publishes row-change events
    β–Ό
SQS / Kafka topic: pin-changes
    β”‚
    β–Ό
Indexing consumer
    β”‚  filters: only process events where new status = "ready"
    β”‚  (ignores uploading β†’ processing transitions)
    β–Ό
Elasticsearch upsert
    { id, description, created_at, suggest: [tokenised description terms] }

Failure mode: If Elasticsearch is down, events queue in SQS. The consumer retries with exponential backoff. When ES recovers, events replay in order. The Postgres row is the source of truth β€” a full re-index is always possible by replaying from created_at order.


# 9. Caching

Browser request: GET /search?q=keyboards
       β”‚
       β–Ό
CDN (first page, hot queries)
  HIT β†’ return cached SSR HTML (sub-10ms)
  MISS ↓
       β”‚
       β–Ό
Node.js API
       β”‚
       β–Ό
Redis  HIT β†’ return JSON (< 5ms)
  MISS ↓
       β”‚
       β–Ό
Elasticsearch (~20–50ms)
       β”‚
       β–Ό
Write to Redis (TTL: 60s)
Return response
Layer What's cached TTL Key
CDN SSR HTML, first page results 60s URL (?q=keywords)
Redis Search result JSON, all cursor pages 60s search:{sha256(q)}:{cursor}
Redis Suggestion results 60s suggest:{sha256(q)}
CDN Image variants 1 year (immutable) UUID-based URL

# 10. Observability

Signals tracked, frontend-first:

  1. Core Web Vitals (per page, real user monitoring)

    • LCP on /search β€” time for first image row to paint. Target: < 2.5s.
    • CLS on /search β€” masonry slot stability. Target: < 0.1. Alert if any slot collapses on image load or error.
    • INP on /search β€” scroll interaction responsiveness. Target: < 200ms.
  2. Search latency p50/p95/p99 β€” measured at the API layer (excludes CDN hits). A p99 spike β†’ check ES cluster health first, then Redis hit rate.

  3. CDC lag β€” time delta between pins.created_at and the ES document's index timestamp. Growing lag β†’ indexing consumer falling behind. Queue depth is the leading indicator; trigger alert at > 5 minutes lag.

  4. Pin creation funnel β€” four-stage success rate tracked per request:

    • Presigned URL issued
    • S3 upload confirmed (ObjectCreated received)
    • Sharp processing complete
    • status=ready written

    A drop at any specific stage isolates the failure (S3 issue vs Sharp crash vs Postgres write failure).


# 11. CDN Strategy & Availability

# Multi-CDN fallback

CDN availability is business-critical for this system β€” images and SSR HTML both route through it. Cloudflare (June 2022) and Fastly (June 2021) each caused widespread outages that took down significant portions of the internet. A single-CDN dependency is an unacceptable SPOF at production scale.

Approach: active/passive multi-CDN with usage-based pricing

Configure two CDN distributions (e.g. Cloudflare as primary, CloudFront as secondary) both pointing at the same origin. Traffic routing via DNS failover (Route 53 health checks, or Cloudflare Load Balancer with health monitors). The secondary CDN is cold β€” it serves no traffic during normal operation. Because both CDNs use usage-based pricing (you pay for bytes served and requests made, not reserved capacity), the passive distribution costs effectively nothing when idle. On primary CDN failure, DNS TTL-based failover routes traffic to the secondary within 30–60 seconds.

                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚   DNS Health Routing     β”‚
                        β”‚  (Route53 / CF LB)       β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    primary  β”€β”€β”€β”€β”€β”€β”˜    └────── fallback (cold)
                        β”‚                          β”‚
               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
               β”‚  Cloudflare    β”‚      β”‚   CloudFront /    β”‚
               β”‚  (primary)     β”‚      β”‚   GCP Cloud CDN   β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚ (both origins point to same object storage)
                        β–Ό
               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
               β”‚  R2 / S3 / GCS  β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Cost impact: Near-zero. The secondary CDN serves no traffic in steady state β€” no bytes billed, no requests billed. The only cost is the health check pings (negligible). This is the reason usage-based CDN pricing makes multi-CDN practical: reserved-capacity CDNs would charge for idle standby.

Cloud-specific CDN pairings:

Cloud preference Primary CDN Secondary CDN Object storage
AWS-native CloudFront Cloudflare S3
Cost-optimised Cloudflare CloudFront R2 or S3
GCP-native GCP Cloud CDN Cloudflare GCS

All three work with the same URL pattern β€” switching CDN is a DNS change, not an application change.

# 12. Cost Controls

Driver Lever
Storage + CDN egress Sharp converts originals β†’ WebP (30–50% smaller). Original deleted after variants confirmed. Object storage and CDN choice follows the team's cloud preference β€” all combinations work: AWS S3 + Cloudflare CDN (S3 is a valid Cloudflare origin; eliminates CloudFront egress cost while retaining S3's ecosystem), Cloudflare R2 + Cloudflare CDN (zero egress fees end-to-end, best economics), GCS + GCP Cloud CDN (natural choice if the team is GCP-native). The storage layer is cloud-agnostic by design β€” only the URL pattern (pins/{uuid}/variant.webp) is stored; switching origins is a CDN config change.
CDN cache hit rate Cache-Control: max-age=31536000, immutable on all image variants. UUID-keyed URLs never change β€” virtually 100% CDN cache hit rate at steady state.
Elasticsearch compute Redis caching cuts ES QPS for hot queries. Only description, created_at, suggest indexed β€” image URLs and full metadata stay in Postgres. ES index lifecycle: warm tier for pins > 90 days, cold tier > 1 year.

# 13. Trade-offs & Key Decisions

See docs/adr/ for full context. Summary:

Decision Rejected alternative Reason
Streaming SSR for search results Blocking SSR Blocking SSR adds ES latency to TTFB directly
Streaming SSR for search results Pure CSR Empty first paint β†’ bad LCP, non-crawlable URLs
CDC for ES sync Dual write Dual write has no clean recovery on partial failure
CDC for ES sync Polling Polling cost grows unboundedly with pin volume
search_after cursor Offset pagination Offset cost grows with depth; duplicate/skip risk on concurrent inserts
JS-calculated masonry + stored dimensions CSS columns CSS columns give wrong insert order for infinite scroll
Virtualized DOM from v1 Defer to Phase 2 Infinite scroll is the core UX β€” main thread degradation is structural, not speculative

# 14. Future Extensions

  • Canvas-based pin editor β€” Fabric.js/Konva.js layer system. Output: composited flat image exported to S3. Replaces the simple file upload form.
  • Video pins β€” mp4 up to 200MB. Requires transcoding pipeline (separate from Sharp), HLS streaming, different CDN configuration.
  • Search ranking v2 β€” fold save_count engagement signal into function_score once event tracking is in place.
  • Personalised suggestions β€” weight ES completions by user's past search terms (requires session history store).
  • Virtual scroll with recycled DOM nodes β€” replace current "render/unmount near viewport" approach with a fixed pool of recycled card elements for lower GC pressure at extreme scroll depth.