# 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 |

βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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.
# Search
#
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_scoreexponential decay oncreated_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 suggestions

# Pin creation

# 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:
- On mount, read container width β calculate column count (e.g. 2 on mobile, 3 on tablet, 4 on desktop).
- Track
columnHeights[]. For each pin, place it in the shortest column. Position:{ top: columnHeights[col], left: col * (colWidth + gap) }. - Pin
heightis known from Postgres (stored at upload time) β calculated as(stored_height / stored_width) * colWidth. Zero layout shift; no waiting for images to load. - 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,Entersubmits,Escapecloses.
# 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:
- Validate β reject if not JPEG/PNG, abort if corrupt.
- Extract metadata β
width,heightof original. - Extract dominant color β quantize to 1 color, store as hex.
- Generate WebP variants β
236w,474w,736w, quality 80. - Write variants to S3 β
pins/{uuid}/236w.webpetc. - Delete original from S3 β cost control.
- 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:
Core Web Vitals (per page, real user monitoring)
LCPon/searchβ time for first image row to paint. Target: < 2.5s.CLSon/searchβ masonry slot stability. Target: < 0.1. Alert if any slot collapses on image load or error.INPon/searchβ scroll interaction responsiveness. Target: < 200ms.
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.
CDC lag β time delta between
pins.created_atand the ES document's index timestamp. Growing lag β indexing consumer falling behind. Queue depth is the leading indicator; trigger alert at > 5 minutes lag.Pin creation funnel β four-stage success rate tracked per request:
- Presigned URL issued
- S3 upload confirmed (
ObjectCreatedreceived) - Sharp processing complete
status=readywritten
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_countengagement signal intofunction_scoreonce 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.