Quick answer
To build a Facebook clone that scales to millions, serve feeds from per-user Valkey lists filled by fanout-on-write, pull high-follower accounts at read time, and rank about 500 candidates with a lightweight model. Keep reactions idempotent with batched counters, use PostgreSQL sharded by user, deliver images as AVIF through a CDN, and run chat over WebSockets.
- Built with Claude Code in about 33 minutes of agent time: 66 files, 13 pages and 12 API routes, with build and lint passing.
- Kinfolk is a working Facebook-style clone in Next.js 16, React 19 and Tailwind v4: feed, reactions, comments, stories, composer, chat, search, groups, marketplace, video and profiles all function.
- Feeds are read about 100 times more often than they are written, so serve them from per-user Valkey sorted sets built by fanout-on-write, and pull high-follower accounts at read time (hybrid fanout).
- Rank about 500 candidates per request with affinity, freshness and engagement features and a lightweight model. A hand-tuned score beats chronological order on day one.
- Make reactions idempotent rows and keep counters out of the hot path: Valkey deltas, batched flushes, nightly reconciliation.
- Image egress, not database load, dominates cost. AVIF/WebP renditions at the right width plus a CDN are mandatory.
- Chat needs per-conversation sequence numbers, client-generated IDs and watermark receipts. Presence should be pulled for visible contacts, not broadcast.
- On Hetzner, start with cloud instances and Docker Compose, move to k3s, then put databases and gateways on dedicated AX servers. Run PostgreSQL yourself with Patroni and pgBackRest.
- Built with Claude
- DesignClaude design canvasThe app screens were designed with Claude on a design canvas, one board per product.
- CodingClaude CodeA Claude Code sub-agent wrote Kinfolk: 66 files, 13 pages, 12 API routes, in about 33 minutes.
- Parallel agentsClaude Code sub-agentsSix clones were built at the same time, one sub-agent per app.
- Testing & screenshotsClaude CodeBuild, lint, a running server and 7 screenshots that Claude reviewed and fixed.
- Scalability & infrastructure analysisClaude Opus 5.5Capacity math, Hetzner server plans, real-time design, security and cost models.
- Source code
- github.com/suhasbhairav/facebook-cloneMIT license
- Run it locally
- git clone https://github.com/suhasbhairav/facebook-clone.git && cd facebook-clone && npm install && npm run dev
- Stack
- Built with Claude CodeDesigned with ClaudeAnalysed with Claude Opus 5.5Next.js 16 (App Router, Cache Components)React 19Tailwind CSS v4Next.js Route Handlers (in-memory demo API)useSyncExternalStore + localStorage persistenceYouTube nocookie embeds (The Bhairav Show)Production: PostgreSQL 17, PgBouncer, PatroniProduction: Valkey, NATS JetStreamProduction: Meilisearch / OpenSearchProduction: k3s on Hetzner Cloud + AX dedicated
- Palette
- Accent blue #1877F2Page background #F0F2F5Primary text #050505Secondary text #65676BDivider #CED0D4Button grey #E4E6EBOnline green #31A24CLive red #F02849Photo green #45BD62Feeling yellow #F7B928Saved purple #A033FFMemories teal #2ABBA7
Screenshots of Kinfolk







What works in the clone
News feed with cursor pagination
GET /api/posts returns pages of five posts with a nextCursor. The Feed component loads more through an IntersectionObserver sentinel 600px below the last card, merges in your own posts from localStorage, and drops deleted or duplicate items.
Reactions with a hover picker
Like toggles on click. Holding the pointer over it for 450ms (or long-pressing on touch) opens Like, Love, Haha, Wow and Sad, drawn as inline SVG faces. Counts and the 'You and N others' summary update optimistically, and a PATCH tells the server.
Threaded comments
Add comments, like them, and reply one level deep. New comments are stored locally and POSTed to /api/posts/:id/comments with a client ID, so server copies deduplicate against local ones.
Share, save, hide and delete
Share copies the /posts/:id link to the clipboard and increments the share count. The options menu saves (shown on /saved), hides with an Undo card, copies the link, opens the detail route, and deletes your own posts.
Stories with create and viewer
Create a photo story (downscaled on a canvas) or a text story with a background colour. The viewer at ?story=N has segmented progress bars, tap zones, auto-advance, pause, keyboard support, replies that become chat messages, and inline YouTube Shorts from Suhas Bhairav.
Composer with photo, feeling and audience
The modal at ?compose=1 handles text, a photo upload with preview, a feeling picker and a Public/Friends audience. It posts to POST /api/posts and keeps the post in localStorage so it survives a server restart. Live video opens a real camera preview, with the broadcast simulated.
Video posts from The Bhairav Show
Video posts from the Suhas Bhairav page show the channel thumbnail and duration first. The privacy-enhanced youtube-nocookie iframe mounts only when clicked, with a Watch on YouTube link alongside.
Docked Messenger-style chat
Clicking a contact opens a chat window. Several can be open at once, and each can be minimised to an avatar bubble with an unread badge or closed. Sending shows a typing indicator and then a reply from /api/chat. Everything is persisted, and on phones the chat is a bottom sheet.
Header search, notifications and menus
Debounced search dropdown and a /search page. Notifications support All/Unread, mark-as-read on click and Mark all as read. The Messenger list shows unread state, the menu grid links every section, and the profile menu can reset the demo data.
Real section routes
Video (/watch), Marketplace (categories, item modal, message seller, create listing), Groups (join/leave, group pages), Gaming (three playable games), Friends, Memories, Saved, Events and Profile are all working routes, not placeholders.
Profiles
/profile/arjun shows a cover photo, a large avatar, a friend facepile, Posts/About/Friends/Photos tabs, an editable bio and Add to story. Other users get Add friend and Message buttons, and Suhas Bhairav's page has Follow and a Videos tab.
Responsive down to phone width
The left sidebar hides below 1100px and contacts below 900px. Below 768px the section tabs move to a second header row with a Menu tab, search becomes a full-screen overlay, and cards run edge to edge.
What the Kinfolk Facebook clone is
Kinfolk is a working social network front end in the style of Facebook. It is built with Next.js 16 (App Router), React 19 and Tailwind CSS v4. It follows a specific design file pixel for pixel: the #F0F2F5 page background, the #1877F2 accent, #65676B secondary text, #CED0D4 dividers, white cards with a 10px radius and a one-pixel shadow, the Segoe UI / Helvetica / Arial font stack, and the familiar three-column layout. That layout has shortcuts on the left, the feed in a 680px centre column, and contacts on the right. The brand in the UI is Kinfolk. There is no real company name, logo or trademark anywhere in the app.
The clone is not a static mock-up. Every visible control does something:
- Search queries a route handler.
- Section tabs (Home, Video, Marketplace, Groups, Gaming) are real routes with content.
- Stories can be created from an uploaded photo or as a text card, and are played back in a full-screen viewer with progress bars.
- The composer publishes posts with text, a photo, a feeling and an audience.
- Reactions have a hover picker.
- Comment threads support replies and likes.
- Share copies a link and increments a counter.
- Saved and hidden posts are persisted.
- Contacts open docked chat windows that show a typing indicator and then a simulated reply.
The real media comes from Suhas Bhairav's YouTube channel, The Bhairav Show. Video posts in the feed show the thumbnail first. Clicking play mounts a privacy-enhanced youtube-nocookie.com iframe in place, with a "Watch on YouTube" link next to it. Some stories are Suhas's YouTube Shorts, and the Video tab is a feed of the channel's long-form episodes and Shorts. Every other photo comes from picsum.photos with stable seeds, so a post shows the same picture on every load.
What is real and what is simulated
| Area | In the clone | In production |
|---|---|---|
| Feed, posts, comments | Route handlers with cursor pagination, plus in-memory server state and localStorage | PostgreSQL, Valkey feed cache, fanout workers |
| Reactions and counters | Optimistic client state, PATCH to the API | Idempotent reaction rows plus asynchronously aggregated counters |
| Stories | POST to /api/stories, image downscaled in the browser | Presigned upload, media pipeline, 24-hour TTL |
| Chat | Canned replies from /api/chat after a typing delay | WebSocket gateway, per-conversation sequence numbers, receipts |
| Presence | Static seed flags | Heartbeats in Valkey with TTL, pushed to friends |
| Live video | Real camera preview via getUserMedia, broadcast simulated | WHIP/WebRTC ingest to an SFU or LL-HLS packager |
| Auth | Always signed in as Arjun Kumar | OIDC plus passkeys, server sessions |
The rest of this guide treats the clone as the front end of a real product. It covers what you would build behind it to serve millions of people: the data model, the API, the feed fanout and ranking system, the realtime layer, a concrete Hetzner deployment for each growth stage, security, operations and cost.
How I built Kinfolk with Claude: design, code and scaling plan
Kinfolk was built with Claude at every step. I designed it with Claude on a design canvas in Claude, coded and tested it with Claude Code, and used Claude Opus 5.5 to work out how it scales to millions of users. Here is the actual workflow, including what I decided and what Claude did.
| Stage | Claude tool | What it did |
|---|---|---|
| Design | Claude design canvas | The app screens were designed with Claude on a design canvas, one board per product. |
| Coding | Claude Code | A Claude Code sub-agent wrote Kinfolk: 66 files, 13 pages, 12 API routes, in about 33 minutes. |
| Parallel agents | Claude Code sub-agents | Six clones were built at the same time, one sub-agent per app. |
| Testing & screenshots | Claude Code | Build, lint, a running server and 7 screenshots that Claude reviewed and fixed. |
| Scalability & infrastructure analysis | Claude Opus 5.5 | Capacity math, Hetzner server plans, real-time design, security and cost models. |
1. Design with Claude
I designed the screens with Claude on a design canvas: six app screens, one board per product, each in the palette of the product it is modelled on. The Kinfolk design uses an original name and original copy, so the clone borrows the product category and layout conventions, not Facebook's brand.
2. One shared brief, written with Claude Code
Before any app code, Claude Code wrote one brief that all six builds followed. It read the design boards from the Claude canvas, pulled my channel's videos from YouTube, and fetched current prices from Hetzner. The brief covered:
- Stack: the existing
create-next-appscaffold (Next.js 16, React 19, Tailwind CSS v4). Next 16 has breaking changes, so Claude was told to read the docs bundled innode_modules/next/dist/docs/rather than rely on memory. - Fidelity: match the design's layout, hex colours, fonts and spacing; replace grey placeholders with real media; use stroke SVG icons, not emoji.
- "Fully working": every visible control must do something, backed by Next.js route handlers so the UI talks to an API like a real product.
- Media: real videos from @suhasbhairav played through privacy-enhanced embeds.
- Proof: build, run, screenshot every main flow, look at each screenshot, fix what's wrong.
3. Coding with Claude Code sub-agents, in parallel
The main Claude Code session started six sub-agents, one per clone, each owning one folder and one port, so the six apps were built at the same time. Every agent ran on Claude Opus 5.5. The Kinfolk agent worked for about 33 minutes. In that run Claude:
- Read the design file and the Next.js 16 docs it needed.
- Wrote the app: 66 source files, about 4,642 lines, with 13 page routes and 12 API route handlers (
/api/chat,/api/events,/api/groups,/api/marketplace,/api/notifications,/api/people,/api/posts,/api/posts/[id],/api/posts/[id]/comments,/api/search,/api/stories,/api/videos). - Ran
npm run buildandnpm run lintuntil both passed cleanly. - Started the production server and drove headless Chrome to capture 7 screenshots. For true phone-width shots it wrote a small Chrome DevTools Protocol script, because Chrome's
--window-sizeflag won't lay out narrower than about 500px. - Opened every screenshot, fixed the bugs it saw, and re-shot.
4. Analysing scalability with Claude
The sections that follow, from the data model to the cost breakdown, were drafted with Claude Opus 5.5. That covers capacity math, the Postgres and caching plan, Hetzner server choices per growth stage, the real-time notification design, security and monthly costs. Each one is marked where it starts. Claude worked from Hetzner's own price pages, fetched that day, rather than from memory, and showed its arithmetic so the estimates can be checked.
5. Review: Claude checking Claude
The main Claude Code session reviewed each agent's output instead of trusting it. It looked at the screenshots and rendered every page. It also caught an inconsistency across the six write-ups: some cost tables listed the price of one server where others listed the row total. All tables were normalised to show both. The agents were explicit about which parts are simulated (in-memory server state, fake payments or uploads where relevant) and which paths were not tested, and this page keeps that explicit.
What this means if you want to do the same
- Spend your time on the brief, not the boilerplate. A precise brief (stack, fidelity bar, definition of "working", proof required) is what made six parallel Claude Code builds come back consistent.
- Make Claude prove it. "Build passes, here are screenshots I looked at" is a much stronger signal than "done".
- Keep the architecture decisions yours. Claude wrote the code and the first draft of the scaling plan; deciding what is real, what is simulated and what goes to production is still the engineer's job.
- Start here: Claude Code overview · Sub-agents · Claude Opus
Page routes in the clone
/ · /events · /friends · /gaming · /groups · /marketplace · /memories · /menu · /posts/[id] · /profile/[handle] · /saved · /search · /watch
Screens walkthrough
These screenshots come from the production build (npm run build && next start), driven by headless Chrome. Each one is a URL-addressable state, so you can reproduce it by opening the same path.
Home feed
The home route (/) shows the design's three columns.
- Left column. Arjun's profile link, then the seven shortcuts from the design (Friends, Memories, Saved, Groups, Video, Marketplace, Events), each with an icon on its brand colour, followed by the groups you have joined.
- Centre column. The stories row: a Create story card plus one card per story, with a scroll button. Below it the composer card with Live video, Photo/video and Feeling/activity, then the feed.
- Right column. Contacts with green online dots and last-seen times for people who are offline, followed by a friend request and an upcoming event.
- Header. The search field, five section tabs with an active underline, and the Menu, Messenger, Notifications and profile buttons. The badge counts come from the API and your read state.
Story viewer
Opening /?story=1 plays the second story in the row, here a set of Suhas Bhairav's Shorts.
- Playback. Each frame gets its own progress segment. Frames advance automatically every five seconds. Tapping the left or right third of the card steps back or forward. The arrow keys and space bar also work.
- Shorts. A frame that is a YouTube Short shows its thumbnail and a "Watch short" button. Pressing it pauses the timer and mounts the looping Shorts embed.
- Replies. The reply box at the bottom sends a real chat message to the story's author and opens the chat window.
- Story list. On desktop, the left panel lists every story and highlights the current one.
Composer
The composer is a modal that you can link to (?compose=1, or ?compose=photo to open it with the photo picker showing).
- Photo. The browser downscales the uploaded image to at most 1280px on a canvas before it is previewed or sent, which keeps
localStorageand the request small. - Feeling. The feeling panel turns the header into "Arjun Kumar is feeling excited".
- Audience. The selector switches between Public and Friends.
- Publishing. Posting calls
POST /api/posts, prepends the returned post to the feed and shows a toast.
Feed interactions: reactions, comments and chat
This screenshot shows three systems running at once.
- Reaction picker. Hovering Like for about 450ms opens a picker with five reactions (Like, Love, Haha, Wow, Sad), all drawn as SVG.
- Comment thread. The thread is open with a nested reply, like counts on the bubbles, and the comment input focused.
- Chat window. Priya's chat window is docked bottom right. The message Arjun sent has already received a simulated reply. Before a reply arrives, three animated dots show that the other person is typing. Chat windows can be minimised to avatar bubbles with unread badges, closed, or stacked up to three wide, and they persist across reloads.
Video tab
The Video tab (/watch) loads the channel through /api/videos.
- Shorts row. Each Short plays inline as a looping embed.
- Episode feed. One card per episode, with Like, Save, Share and a YouTube link.
- Player.
/watch?v=<id>pins a selected video at the top and starts it. - Tabs. The side navigation filters to Shorts, Full episodes (ten minutes or longer) or Saved videos.
Search
Typing in the header search shows a dropdown with debounced results after 180ms. Pressing Enter opens /search?q=. The route handler matches on word prefixes, so "ai" finds "AI agents" but not "Chennai". It returns people, groups, videos and posts, and the side tabs narrow the results by type.
Phone width
At phone width the layout changes:
- Sidebars. Both sidebars are hidden.
- Header. It shows the wordmark plus search, Messenger, notifications and profile buttons.
- Tabs. The section tabs become a second row that adds a Menu tab, which goes to
/menu. - Cards. Cards run edge to edge.
- Chat. A chat opens as a bottom sheet instead of a docked window.
Frontend architecture
The clone is a JavaScript App Router project with no state library and no UI kit. It uses about 4,500 lines of components and route handlers.
Routes
app/
layout.js AppShell: Header, ChatDock, Toast, URL overlays
page.js / stories + composer + feed
watch/page.js /watch channel videos, ?v=, ?tab=
marketplace/page.js /marketplace ?cat=, ?item=, ?sell=1
groups/page.js /groups ?g=, ?tab=about, ?view=discover
gaming/page.js /gaming ?play=tictactoe|memory|reaction
friends/ memories/ saved/ events/ search/ menu/
posts/[id]/page.js post detail (comments open)
profile/[handle]/page.js profile, ?tab=posts|about|friends|photos|videos
api/ route handlers (see API design)
components/
Header.js LeftNav.js Contacts.js ChatDock.js
Stories.js StoryViewer.js CreateStoryModal.js useStories.js
ComposerBar.js ComposerModal.js LiveModal.js
Feed.js PostCard.js Comments.js VideoEmbed.js
Icons.js (stroke icons + SVG reaction faces) Avatar.js Modal.js
pages/*.js one client component per section
lib/
seed.js demo data, channel videos, picsum / ytimg / nocookie helpers
db.js in-memory server store on globalThis, word-prefix search
store.js localStorage store via useSyncExternalStore, actions, useApi
image.js canvas downscaling for uploads
Rendering model
Cache Components is enabled in next.config.mjs, which changes the default rendering model. Pages that read useSearchParams() are wrapped in a <Suspense> boundary, so the static shell (header and layout) can be prerendered while the URL-dependent part renders on the client. The dynamic routes /posts/[id] and /profile/[handle] are partially prerendered: an async inner component awaits params inside <Suspense>.
GET route handlers that read request.url become dynamic automatically. That matters for /api/posts, /api/search and /api/stories, which have to show writes made after the build. The seed-only handlers (/api/groups, /api/events) are prerendered as static JSON.
State
There are two kinds of state, and keeping them apart is the main architectural decision on the client.
- Server data is fetched with a small
useApi(url)hook: fetch, set state, cancel on unmount. The feed uses its own cursor-paginated loader with anIntersectionObserversentinel 600px below the last card. In production this would move to TanStack Query or SWR to get cache keys, deduplication and background revalidation. - User state lives in
lib/store.js, a module-level store read throughuseSyncExternalStore. It covers reactions, saves, hidden posts, your posts and stories, chats, read notifications, group membership, RSVPs and game stats. The server snapshot is a constant default, so hydration always matches, and the client then switches to the persisted snapshot. Every write is wrapped in try/catch, because private windows and full quotas throw. Astoragelistener keeps multiple tabs in sync.
Actions such as react(), sharePost(), addComment(), createPost() and sendMessage() update the store optimistically and then call the API without waiting for it. This is the same pattern you would keep in production, with a retry queue and server reconciliation added.
URL-addressable overlays
Anything you might want to link to or screenshot is in the URL. An Overlays component inside the shell reads the search params and mounts the matching overlay:
?story=NmountsStoryViewer?compose=1|photo|feelingmountsComposerModal?create=storymountsCreateStoryModal?live=1mountsLiveModal
Closing an overlay calls router.replace without that parameter and with scroll: false, so the feed position is kept.
Media in the feed
VideoEmbed renders the hqdefault.jpg thumbnail with a play overlay and the duration, and only mounts the iframe after a click. A feed of twenty embeds would otherwise load twenty YouTube players, which costs megabytes of JavaScript. Images use plain <img loading="lazy">. The production path is described in the media pipeline section.
Production data model
The schema below is PostgreSQL 17. It is designed to start on a single primary and to shard by user later without rewriting queries. IDs are 64-bit and time-sortable (Snowflake-style: 41 bits of milliseconds, 10 bits of shard, 12 bits of sequence). That means ORDER BY id DESC is chronological, and the shard that owns a row can be read straight from the ID.
-- People and pages share one table; pages have kind = 'page'.
CREATE TABLE users (
id bigint PRIMARY KEY,
kind text NOT NULL DEFAULT 'person' CHECK (kind IN ('person','page')),
handle citext UNIQUE NOT NULL,
display_name text NOT NULL,
avatar_key text,
cover_key text,
bio text CHECK (length(bio) <= 160),
follower_count bigint NOT NULL DEFAULT 0, -- denormalised, updated async
created_at timestamptz NOT NULL DEFAULT now()
);
-- Social graph: one row per direction. Friendship = two rows with state 'friend'.
CREATE TABLE edges (
src_id bigint NOT NULL,
dst_id bigint NOT NULL,
kind smallint NOT NULL, -- 1 friend, 2 follow, 3 block, 4 request
affinity real NOT NULL DEFAULT 0, -- refreshed by the ranking job
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (src_id, kind, dst_id)
);
CREATE INDEX edges_dst ON edges (dst_id, kind, src_id); -- "who follows me"
CREATE TABLE posts (
id bigint NOT NULL,
author_id bigint NOT NULL,
group_id bigint,
audience smallint NOT NULL, -- 1 public, 2 friends, 3 group, 4 only me
body text,
feeling text,
media jsonb NOT NULL DEFAULT '[]', -- [{key, w, h, blurhash, kind}]
link_video text, -- e.g. a YouTube id
created_at timestamptz NOT NULL,
deleted_at timestamptz,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE posts_2026_11 PARTITION OF posts
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE INDEX posts_author ON posts (author_id, id DESC) WHERE deleted_at IS NULL;
CREATE INDEX posts_group ON posts (group_id, id DESC) WHERE group_id IS NOT NULL;
CREATE TABLE comments (
id bigint PRIMARY KEY,
post_id bigint NOT NULL,
parent_id bigint, -- one level of replies
author_id bigint NOT NULL,
body text NOT NULL CHECK (length(body) <= 8000),
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX comments_post ON comments (post_id, id);
-- One reaction per user per post; changing the type updates in place.
CREATE TABLE reactions (
post_id bigint NOT NULL,
user_id bigint NOT NULL,
type smallint NOT NULL, -- 1 like 2 love 3 haha 4 wow 5 sad
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
-- Counters live apart from posts so hot posts don't bloat the posts heap.
CREATE TABLE post_counters (
post_id bigint PRIMARY KEY,
likes bigint NOT NULL DEFAULT 0,
by_type int[] NOT NULL DEFAULT '{0,0,0,0,0}',
comments bigint NOT NULL DEFAULT 0,
shares bigint NOT NULL DEFAULT 0
);
CREATE TABLE stories (
id bigint PRIMARY KEY,
author_id bigint NOT NULL,
frame jsonb NOT NULL, -- {kind:'image', key} | {kind:'text', text, bg}
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL -- created_at + 24h; swept by a job
);
CREATE INDEX stories_author_live ON stories (author_id, expires_at DESC);
CREATE TABLE conversations (id bigint PRIMARY KEY, kind smallint NOT NULL, last_seq bigint NOT NULL DEFAULT 0);
CREATE TABLE conversation_members (
conversation_id bigint NOT NULL,
user_id bigint NOT NULL,
delivered_seq bigint NOT NULL DEFAULT 0, -- receipts are watermarks, not rows
read_seq bigint NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE messages (
conversation_id bigint NOT NULL,
seq bigint NOT NULL, -- gapless per conversation
sender_id bigint NOT NULL,
client_id uuid NOT NULL, -- idempotency key from the device
body text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, seq),
UNIQUE (conversation_id, client_id)
);
CREATE TABLE notifications (
user_id bigint NOT NULL,
id bigint NOT NULL,
kind smallint NOT NULL,
actor_id bigint,
object_id bigint,
read_at timestamptz,
PRIMARY KEY (user_id, id)
);
Indexing and partitioning choices
- The edge table is keyed by
(src_id, kind, dst_id). Both "my friends" and "who I follow" are then a single index range scan. The reverse index answers "who follows this page", which is what fanout needs. - Posts are range-partitioned by month. Old partitions can be detached to cheaper storage, and
VACUUMstays bounded. The posts primary key includescreated_atbecause PostgreSQL requires the partition key to be part of the primary key. - Reactions use a natural key of
(post_id, user_id). That makes every reaction write idempotent: tapping Like twice cannot create two rows. - Counters are a separate, narrow table. Updates to a viral post rewrite a roughly 60-byte row instead of a several-kilobyte post row. At scale the increments are batched, as described in the deep dive.
- Chat uses per-conversation sequence numbers, and receipts are watermarks. A delivered or read receipt is one
UPDATEofread_seqinstead of one row per message per reader.
Sharding key
Shard almost everything by author or owner user ID: users, edges (by src_id), posts (by author_id), stories, notifications (by recipient), and conversation members (by user). Comments and reactions shard by post_id, so a post's engagement sits next to the post. Messages shard by conversation_id.
Start with Citus or application-level routing over 64 logical shards placed on 4 physical clusters. Moving a logical shard is then a replication job, not a re-key.
API design
The clone's route handlers are a small version of the production API. In production the same resources sit behind /v1, return JSON, and use cursor pagination everywhere.
| Method | Endpoint | Purpose | Notes |
|---|---|---|---|
| GET | /v1/feed?cursor= | Ranked home feed | Opaque cursor; 10 items; 60s private cache |
| GET | /v1/users/:id/posts?cursor= | Profile timeline | Keyset on id DESC |
| POST | /v1/posts | Create post | Idempotency-Key header required |
| GET / DELETE | /v1/posts/:id | Read / soft delete | Audience checked on read |
| PUT / DELETE | /v1/posts/:id/reaction | Set or clear my reaction | PUT body {type}; idempotent by design |
| GET / POST | /v1/posts/:id/comments?cursor= | Thread / add comment | Replies via parent_id |
| POST | /v1/posts/:id/shares | Share or copy-link event | Counted async |
| PUT / DELETE | /v1/me/saved/:postId | Save / unsave | |
| POST | /v1/media/uploads | Get presigned upload URL | Returns {key, url, fields} |
| GET / POST | /v1/stories | Friends' live stories / create | Grouped by author |
| GET | /v1/search?q=&type=&cursor= | People, posts, groups, videos | Personalised ranking |
| GET | /v1/notifications?cursor= | Inbox | POST /read with a watermark |
| GET / POST | /v1/conversations/:id/messages?after_seq= | History / send | client_id for dedupe |
| POST | /v1/push/subscriptions | Register Web Push endpoint | Stores VAPID subscription |
Cursor pagination
Offsets break on a feed. A new post arrives while you scroll, and page two then repeats the last item of page one. Timelines therefore use keyset pagination on the time-sortable ID:
SELECT id, author_id, body, media, created_at
FROM posts
WHERE author_id = $1 AND deleted_at IS NULL AND id < $2 -- $2 = last id seen
ORDER BY id DESC
LIMIT 11; -- one extra = has_more
The ranked home feed is different, because its order is not a column. Its cursor is an opaque, signed token that encodes the ranking snapshot ID and the position within it. A refresh starts a new snapshot, and scrolling continues the old one, so a user never sees duplicates. The clone uses an integer offset as the cursor, which is fine for a fixed in-memory list, and the API still returns nextCursor so the client code does not change.
Idempotency
Mobile networks retry requests. POST /v1/posts and POST /messages accept an Idempotency-Key, or for messages a client_id. The server stores (user_id, key) → response in Valkey for 24 hours with SET NX. A replayed request gets the original response instead of creating a duplicate post.
Reactions are idempotent through the database's natural key: PUT with the same type does nothing, and a different type is an update. The clone does the same thing on the client by computing the next reaction from the previous one.
Rate limits
Rate limits are applied per user and per IP in a Valkey token bucket, at the edge and again in the app:
- Posts: 10 per minute.
- Comments: 30 per minute.
- Reactions: 120 per minute.
- Friend requests: 50 per day, with a lower limit for new accounts.
- Search: 60 per minute.
- Messages: 60 per minute per conversation.
Responses carry the standard RateLimit-Policy and RateLimit headers and return 429 with Retry-After. New accounts get stricter buckets, because spam mostly comes from fresh accounts.
Scaling to millions of users
Capacity model and architecture drafted with Claude Opus 5.5 inside Claude Code. All figures are estimates with the arithmetic shown.
The numbers below are estimates for planning, with the arithmetic shown. They are not measurements of the clone.
Back-of-envelope sizing at 10M MAU
| Quantity | Assumption | Arithmetic | Result |
|---|---|---|---|
| DAU | 40% of MAU (social apps run high) | 10M × 0.4 | 4M DAU |
| Feed page loads | 8 sessions × 3 pages per DAU | 4M × 24 | 96M/day ≈ 1,110/s avg, ~3,300/s peak (3×) |
| Posts | 5% of DAU post once a day | 4M × 0.05 | 200K/day ≈ 2.3/s |
| Reactions | 10 per DAU per day | 4M × 10 | 40M/day ≈ 460/s avg, ~1,400/s peak |
| Comments | 1 per DAU per day | 4M × 1 | 4M/day ≈ 46/s |
| Fanout inserts | 300 average audience per post | 200K × 300 | 60M/day ≈ 700/s avg |
| Photos | 30% of posts have one | 200K × 0.3 | 60K/day |
| Photo storage | 2.5 MB original + 0.5 MB renditions | 60K × 3 MB | ~180 GB/day ≈ 5.4 TB/month |
| Image egress | 2.5 images × 80 KB per feed page | 96M × 200 KB | ~19 TB/day ≈ 576 TB/month |
Two numbers dominate. Reads outnumber writes by about 100 to 1, so the feed has to be served from memory. And image egress is about 500 times larger than the database traffic, so a CDN is required and the origin's job is mainly cache fill.
Architecture
- Edge. DNS and CDN (Cloudflare or Bunny) serve images, JS and CSS, and cache public profile pages. They also provide WAF and bot rules.
- Load balancers. Hetzner Load Balancers terminate TLS, or pass TCP through to an ingress.
- App tier. Stateless Next.js and API pods run on k3s, with sessions in Valkey, so any pod can serve any request. Scale on CPU and p95 latency.
- Realtime gateway. A separate WebSocket fleet holds long-lived connections for chat, typing, presence and live counters, scaled on connection count.
- Valkey cluster. It holds per-user feed lists (sorted sets), sessions, rate-limit buckets, counter deltas, presence keys and idempotency keys.
- PostgreSQL. One primary per shard group plus two streaming replicas, with PgBouncer in transaction mode in front. Reads that can tolerate about 100ms of lag go to replicas.
- Event backbone. NATS JetStream (or Kafka/Redpanda at hyperscale) carries
post.created,reaction.changed,comment.created,edge.changedandmedia.uploadedevents to the workers. - Workers. Separate workers handle fanout, counter aggregation, notifications, the media pipeline, search indexing and ranking feature updates.
- Object storage. S3-compatible buckets hold originals and renditions, with the CDN pulling from them.
- Search. Meilisearch first, then an OpenSearch cluster, both fed from the event stream.
Caching strategy
- Feed lists. Each user has a Valkey sorted set
feed:{uid}of post IDs scored by time, capped at 800 entries. A user's first request after being inactive rebuilds it from the graph (cold start). - Post objects. Hydrated post JSON is cached in Valkey (
post:{id}, 10 minutes) with a separate counters hash. The feed endpoint does a singleMGETfor ten posts and a pipeline ofHGETALLcalls for their counters. - Negative caching covers deleted or hidden posts, so they are not hydrated again.
- CDN. Media is immutable with content-hashed keys and
Cache-Control: public, max-age=31536000, immutable. HTML for signed-in users is never cached at the CDN.
Database scale path
- Start: one primary and one replica, with PgBouncer and pgBackRest from day one.
- Growth: more replicas. Move feed hydration reads and search indexing to replicas, and make
postsandnotificationspartitioned tables. - Scale: shard by user ID. Use Citus or application routing across 64 logical shards. Keep
edgesco-located withusers. - Hyperscale: split by workload. Separate clusters for social data, messaging and notifications, so a hot chat cluster cannot slow down feed reads.
Multi-region
Hetzner's dedicated servers are in Germany and Finland, and its cloud also runs in Ashburn, Hillsboro and Singapore. A realistic plan:
- Primary writes stay in the EU. That also simplifies GDPR.
- Read replicas in the US. Run Valkey feed caches and read-only app pods in Ashburn on cloud instances, fed by asynchronous replication.
- Writes from the US go to the EU primary. A post takes about 90ms longer to save, which users rarely notice.
- Chat gateways run in every region. NATS clusters connected by leaf nodes route messages between them, so typing indicators stay local.
Deploying on Hetzner
Server plan drafted with Claude from Hetzner's own price pages, fetched by Claude Code on 11 November 2026. Verify prices on hetzner.com before ordering.
Hetzner is a good fit for this workload because the costs are bandwidth and RAM, both of which it prices aggressively. Know the limits before you commit:
- There is no managed PostgreSQL, so you run it yourself or use an external provider.
- Locations are Germany (Falkenstein, Nuremberg), Finland (Helsinki), the USA (Ashburn, Hillsboro) and Singapore. Dedicated AX servers are only in Germany and Finland.
- Global latency means pairing it with a CDN such as Cloudflare or Bunny.
- Every product includes DDoS protection.
- Some shared-vCPU cloud plans are shown as "currently not available" in some locations. Check the console before planning around a specific size.
Prices below are EUR per month, excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering.
Launch (0–50K MAU)
- App and API. Two CPX32 (4 vCPU / 8 GB, €30.49 each in HEL1) behind an LB11 (from about €6/month, check the console). Docker Compose on each server, deployed with Ansible.
- PostgreSQL 17. A CCX23 primary (dedicated vCPU) and a second CCX23 as a streaming standby, managed with Patroni or just repmgr. PgBouncer runs on the same box.
- Valkey and NATS. One CPX22 hosts Valkey, a single-node NATS JetStream and the background workers.
- Object Storage. One bucket for media (€6.49 base, including 1 TB of storage and 1 TB of egress) and one for backups.
- Monitoring. A CPX22 running Prometheus, Grafana and Loki.
- Network. Everything sits on a Hetzner Cloud Network (10.0.0.0/16). Only the load balancer has public HTTP. The database and Valkey listen only on private IPs. A Hetzner Cloud Firewall denies everything inbound except 443 on the LB and SSH from a bastion or Tailscale.
Growth (~500K MAU)
- Move to k3s. Run three small control-plane nodes and add worker nodes. Use the Hetzner Cloud Controller Manager and CSI driver, so
LoadBalancerservices provision Hetzner LBs and PVCs become Hetzner Volumes. - App and API. Four CPX42 nodes, with a Horizontal Pod Autoscaler on CPU and request latency.
- Realtime. The WebSocket gateway gets two dedicated CPX32 nodes and its own LB with long idle timeouts.
- PostgreSQL. Three CCX33 nodes (primary plus two replicas) managed by Patroni with etcd. Volumes are avoided for the data directory. Use the local NVMe of dedicated-vCPU instances, and rely on replicas and pgBackRest for durability.
- Supporting services. A Valkey primary and replica (CCX23), a three-node NATS JetStream cluster, and Meilisearch on a CCX23.
Scale (~5M MAU): move to dedicated AX servers
At this stage cloud instances cost more per GB of RAM and per core than the AX line, and the database needs predictable disk I/O.
- App and gateway nodes. AX42 servers (8 cores, 64 GB DDR5 ECC, from €79).
- Databases and media workers. AX102 servers (16-core Ryzen 9 7950X3D, 128 GB ECC, 2 × 1.92 TB datacenter NVMe, from €159).
- Networking. The dedicated servers join the existing cloud network through a vSwitch coupled to the Cloud Network. The k3s control plane and load balancers can stay in the cloud while the heavy nodes are bare metal.
- One-off costs. Budget for setup fees (for example €49 for an AX42 and €129 for an AX102), and for a 10 Gbit uplink add-on on database nodes if replication traffic grows.
Hyperscale (10M+ MAU)
- Sharded PostgreSQL. Four shard groups, each a primary and two replicas on AX102.
- Valkey Cluster on AX102 nodes.
- Event log. A Redpanda or Kafka cluster for event replay, with NATS kept for realtime fanout.
- Search. A dedicated OpenSearch cluster.
- Second app region on cloud instances in Ashburn for American users.
Backups and recovery
pgBackRest writes a full backup weekly, a differential daily and continuous WAL archiving to a separate Object Storage bucket (internal traffic and API calls are free). Retention is 14 days of point-in-time recovery. Every month, a scripted drill restores the latest backup to a scratch CCX instance and runs checksums and row counts against the primary.
Valkey data is rebuildable, because feeds can be regenerated from Postgres, so AOF with everysec is enough. Object Storage buckets have versioning enabled.
Hetzner server plan by growth stage
Prices in EUR per month, excl. VAT, as listed on hetzner.com in November 2026. Verify current pricing and availability before ordering.
Launch· 0–50K MAU
≈ €254.43 / monthDocker Compose + Ansible on Hetzner Cloud (HEL1 prices). Postgres primary + streaming standby, pgBackRest to Object Storage, everything on one Cloud Network behind a Cloud Firewall.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (Next.js, route handlers) | CPX32 | 4 vCPU / 8 GB / 160 GB NVMe | 2 | €30.49 | €60.98 |
| PostgreSQL primary + standby (+PgBouncer) | CCX23 | 4 dedicated vCPU / 16 GB / 160 GB | 2 | €73.49 | €146.98 |
| Valkey + NATS JetStream + workers | CPX22 | 2 vCPU / 4 GB / 80 GB | 1 | €16.99 | €16.99 |
| Monitoring (Prometheus, Grafana, Loki) | CPX22 | 2 vCPU / 4 GB / 80 GB | 1 | €16.99 | €16.99 |
| Load Balancer (approx., check console) | LB11 | from ~€6/month | 1 | €6.00 | €6.00 |
| Object Storage (media + backups) | Object Storage | €6.49 base incl. 1 TB storage + 1 TB egress | 1 | €6.49 | €6.49 |
Growth· ~500K MAU
≈ €1,299.28 / monthk3s with the Hetzner cloud controller manager and CSI driver. Patroni-managed Postgres with two replicas. Separate realtime gateway pool. Meilisearch for search. About 20 TB of extra media in Object Storage.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API pods (k3s workers) | CPX42 | 8 vCPU / 16 GB / 320 GB | 4 | €59.49 | €237.96 |
| k3s control plane | CPX22 | 2 vCPU / 4 GB | 3 | €16.99 | €50.97 |
| Realtime WebSocket gateway | CPX32 | 4 vCPU / 8 GB | 2 | €30.49 | €60.98 |
| Workers: fanout, counters, media, notifications | CPX42 | 8 vCPU / 16 GB | 2 | €59.49 | €118.98 |
| PostgreSQL (Patroni: primary + 2 replicas) | CCX33 | 8 dedicated vCPU / 32 GB / 240 GB | 3 | €117.99 | €353.97 |
| Valkey primary + replica (feeds, sessions, counters) | CCX23 | 4 dedicated vCPU / 16 GB | 2 | €73.49 | €146.98 |
| NATS JetStream cluster | CPX22 | 2 vCPU / 4 GB | 3 | €16.99 | €50.97 |
| Search (Meilisearch) | CCX23 | 4 dedicated vCPU / 16 GB | 1 | €73.49 | €73.49 |
| Monitoring + logs | CPX42 | 8 vCPU / 16 GB | 1 | €59.49 | €59.49 |
| Load Balancers: web + realtime (approx.) | LB11 | from ~€6/month each | 2 | €6.00 | €12.00 |
| Object Storage (~21 TB media) | Object Storage | base + 20 TB × ~€6.35/TB-month | 1 | €133.49 | €133.49 |
Scale· ~5M MAU
≈ €3,337.43 / monthHeavy nodes move to dedicated AX servers (setup fees extra: AX42 €49, AX102 €129) joined to the Cloud Network via vSwitch. Control plane and LBs stay in the cloud. CDN (Cloudflare/Bunny) in front of all media, billed separately.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API (k3s workers) | AX42 | Ryzen 7 PRO 8700GE 8c / 64 GB DDR5 ECC / 2×512 GB NVMe | 6 | €79.00 | €474.00 |
| Realtime gateway | AX42 | 8c / 64 GB | 3 | €79.00 | €237.00 |
| Workers: fanout, media renditions, ranking features | AX102 | Ryzen 9 7950X3D 16c / 128 GB ECC / 2×1.92 TB NVMe DC | 2 | €159.00 | €318.00 |
| PostgreSQL (primary + 2 replicas + delayed/analytics replica) | AX102 | 16c / 128 GB ECC / 2×1.92 TB NVMe DC | 4 | €159.00 | €636.00 |
| Valkey cluster | AX42 | 8c / 64 GB | 3 | €79.00 | €237.00 |
| Search (OpenSearch, 3 nodes) | AX42 | 8c / 64 GB | 3 | €79.00 | €237.00 |
| NATS JetStream cluster | CPX32 | 4 vCPU / 8 GB | 3 | €30.49 | €91.47 |
| k3s control plane | CPX22 | 2 vCPU / 4 GB | 3 | €16.99 | €50.97 |
| Monitoring + logs (Prometheus, Loki, Tempo) | AX42 | 8c / 64 GB | 1 | €79.00 | €79.00 |
| Load Balancers (approx.) | LB11 | from ~€6/month each | 3 | €6.00 | €18.00 |
| Object Storage (~150 TB media) | Object Storage | base + 150 TB × ~€6.35/TB-month | 1 | €958.99 | €958.99 |
Hyperscale· 10M+ MAU
≈ €10,333.35 / monthFour Postgres shard groups, Valkey Cluster, a Kafka/Redpanda event log for replay, a dedicated ranking and feature-store tier, and a read-mostly US region on Ashburn cloud instances (US plans include only 1–5 TB traffic). CDN egress (~576 TB/month estimated) is the largest line and is billed by the CDN.
| Role | Hetzner | Spec | Count | Unit € / mo | Total € / mo |
|---|---|---|---|---|---|
| App + API, EU (k3s workers) | AX42 | 8c / 64 GB | 12 | €79.00 | €948.00 |
| App + API, US read region (Ashburn) | CPX31 | 4 vCPU / 8 GB / 160 GB (US pricing) | 8 | €62.99 | €503.92 |
| Realtime gateway (EU) | AX42 | 8c / 64 GB | 6 | €79.00 | €474.00 |
| Workers: fanout, media, notifications | AX102 | 16c / 128 GB ECC | 4 | €159.00 | €636.00 |
| PostgreSQL: 4 shard groups × (primary + 2 replicas) | AX102 | 16c / 128 GB ECC / 2×1.92 TB NVMe DC | 12 | €159.00 | €1,908.00 |
| Valkey Cluster (feeds, counters, presence) | AX102 | 16c / 128 GB ECC | 6 | €159.00 | €954.00 |
| Event log (Redpanda/Kafka) | AX102 | 16c / 128 GB ECC | 3 | €159.00 | €477.00 |
| Search (OpenSearch) | AX102 | 16c / 128 GB ECC | 3 | €159.00 | €477.00 |
| Ranking model serving + feature store | AX102 | 16c / 128 GB ECC | 2 | €159.00 | €318.00 |
| Monitoring + logs | AX102 | 16c / 128 GB ECC | 2 | €159.00 | €318.00 |
| k3s control planes (EU + US) | CPX22 | 2 vCPU / 4 GB | 6 | €16.99 | €101.94 |
| Load Balancers (approx.) | LB11 | from ~€6/month each | 6 | €6.00 | €36.00 |
| Object Storage (~500 TB media) | Object Storage | base + 500 TB × ~€6.35/TB-month | 1 | €3,181.49 | €3,181.49 |
Realtime: presence, chat delivery and notifications
Real-time design and code drafted with Claude Opus 5.5 in Claude Code.
A social network has four realtime flows with different guarantees. Treating them all the same is the most common mistake.
| Flow | Delivery guarantee | Persistence | Transport |
|---|---|---|---|
| Chat messages | At-least-once, deduplicated to exactly-once on display | Postgres, per-conversation sequence | WebSocket, then push if offline |
| Receipts (delivered/read) | Latest value wins | Watermark columns | WebSocket |
| Typing indicators | Best effort, can be dropped | None (TTL 5s) | WebSocket only |
| Presence (online dots) | Eventually consistent, about 30s | Valkey key with TTL | WebSocket subscription |
| Notifications | At-least-once | notifications table | WebSocket, SSE fallback, Web Push/APNs/FCM |
Transport choice
WebSockets are the default for chat because the flow is bidirectional: typing, receipts and sends all go upstream. Server-Sent Events are enough for notification badges and live counters on pages without chat. They work through every proxy and resume for free with Last-Event-ID. WebTransport is promising for unreliable datagrams such as typing indicators, but Safari support and proxy support are still not good enough to rely on.
Run the gateway as a separate deployment from the app. The options are Centrifugo, a Go service, or Node with uWebSockets.js. Long-lived connections and stateless request handlers scale on different signals: connections and memory for the gateway, CPU and latency for the app.
Gateway and backplane
Each gateway node subscribes to NATS subjects for the users currently connected to it. A message to Priya is published to user.<priyaId>. Whichever node holds Priya's sockets receives it. If she has no sockets, the notification worker sees she is offline and sends a push instead.
// gateway.js: Node + ws + NATS. One subscription per connected user.
import { WebSocketServer } from "ws";
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const sc = StringCodec();
const wss = new WebSocketServer({ port: 8080, maxPayload: 64 * 1024 });
const sockets = new Map(); // userId -> { sockets: Set<WebSocket>, sub }
wss.on("connection", async (ws, req) => {
const userId = await authenticate(req); // session cookie -> user id
if (!userId) return ws.close(4401, "unauthorized");
let entry = sockets.get(userId);
if (!entry) {
const sub = nc.subscribe(`user.${userId}`);
entry = { sockets: new Set(), sub };
sockets.set(userId, entry);
(async () => {
for await (const m of sub) {
const data = sc.decode(m.data);
for (const s of entry.sockets) {
// backpressure: drop typing events for slow clients, never messages
if (s.bufferedAmount > 1_000_000 && data.includes('"t":"typing"')) continue;
s.send(data);
}
}
})();
}
entry.sockets.add(ws);
ws.on("message", (raw) => handleUpstream(userId, raw)); // send, typing, read receipts
ws.on("close", () => {
entry.sockets.delete(ws);
if (entry.sockets.size === 0) { entry.sub.unsubscribe(); sockets.delete(userId); }
});
});
Chat ordering, receipts and typing
- Ordering. Every message gets a gapless
seqwithin its conversation, assigned in the same transaction as the insert:UPDATE conversations SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq. Clients render byseq, not by arrival time. Two people sending at the same moment always see the same order. - Exactly-once on screen. The device generates a
client_id(UUID) before sending and shows the message right away as pending. A retried send hitsUNIQUE (conversation_id, client_id)and the server returns the existing row. The client swaps its pending bubble for the confirmedseq. - Reconnects. The client sends the highest
seqit has for each open conversation, and the server replays everything after it from Postgres. A missed realtime event therefore turns into a fetch rather than lost data. - Receipts are watermarks. "Delivered" moves
delivered_seqforward when a device acknowledges, and "read" movesread_seqforward when the conversation is visible. Both are fanned out to the other members as one small event. - Typing is throttled on the client to one event every 3 seconds while keys are pressed. It expires on the receiver after 5 seconds without a refresh and is never stored. The clone simulates the same experience: three bouncing dots, then the reply.
Presence at scale
Online dots look trivial but are expensive. Pushing every status change to every friend is O(friends) per change, and people with 5,000 friends reconnect often.
- Heartbeat. Each connected device refreshes
presence:{uid}in Valkey with a 60-second TTL every 30 seconds. - Read on demand. When the contacts list is visible, the client asks for presence of the roughly 30 contacts it shows, in one
MGET, and subscribes topresence.<uid>changes only for those. - Hysteresis. A user only goes offline after the key has expired and 60 more seconds have passed, which stops the dot from flickering when someone switches between Wi-Fi and cellular.
- Large accounts. Pages and people with huge audiences never broadcast presence.
Notification fanout and Web Push
A notification worker consumes comment.created, reaction.changed and similar events and writes a row to notifications. It aggregates bursts ("Priya and 12 others reacted") inside a 2-minute window keyed by (recipient, object, kind), then publishes to user.<id>. If the user has no live socket, it sends a push: Web Push with VAPID for browsers, APNs for iOS and FCM for Android.
import webpush from "web-push";
webpush.setVapidDetails("mailto:ops@kinfolk.example", process.env.VAPID_PUBLIC, process.env.VAPID_PRIVATE);
export async function pushTo(subscriptions, payload) {
await Promise.all(subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(sub, JSON.stringify(payload), { TTL: 3600, urgency: "normal" });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) await deleteSubscription(sub.endpoint); // expired
else throw err; // retried by the queue with backoff
}
}));
}
Delivery is at-least-once, because the queue retries, so payloads carry the notification ID and the service worker deduplicates with tag. Respect quiet hours and per-user caps, because too many notifications is the fastest way to lose push permission.
Deep dive: the news feed system
Deep-dive drafted with Claude Opus 5.5 in Claude Code.
The feed is the product. It is built from six subsystems: graph storage, fanout, ranking, counters, media and search.
1. Social graph storage
Facebook built TAO, a graph-shaped cache over sharded MySQL, because a social network spends most of its time answering "who is connected to whom" and "what did they do". You don't need TAO at 10M users. You do need the same shape:
- Directed edges
(src, kind, dst)sharded bysrc, so all of one user's outgoing edges are on one shard and "my friends" is one index scan. Friendship is stored as two directed edges, written in one transaction when both endpoints are on the same shard, and with an outbox and a reconciliation job when they are not. - An inverse index
(dst, kind, src)answers "who follows this page". This is the fanout list. For a large page it is millions of rows, so it is read in pages of 1,000 IDs by the fanout worker, never loaded at once. - Cached adjacency lists for hot users:
friends:{uid}as a Valkey set, invalidated byedge.changedevents. - Blocks are edges too. They are checked when the feed is built (filter candidates) and again when posts are hydrated (in case someone blocked after fanout).
2. Fanout-on-write vs fanout-on-read
There are two classic designs.
- Fanout-on-write (push). When Priya posts, a worker inserts the post ID into the feed list of each of her 300 friends. Reading a feed is then one
ZREVRANGE, which is extremely cheap. The cost is write amplification: one post becomes 300 writes, and a page with 2 million followers turns one post into 2 million writes. That delays everyone else's fanout behind it. - Fanout-on-read (pull). Store nothing per reader. At read time, fetch the recent posts of everyone you follow and merge them. Writes are cheap, but each feed load becomes a scatter-gather over hundreds of authors, and reads outnumber writes by about 100 to 1.
The answer is a hybrid. Push for normal authors. Pull for authors above a follower threshold, for example 10,000, and for any author whose fanout queue is backed up. Each user's Valkey feed holds pushed post IDs, and at read time the service merges in recent posts from the small set of "celebrity" accounts the reader follows. That set is usually under 50 accounts, so it is one cached query per account.
// Fanout worker: consumes post.created from NATS JetStream.
const CELEBRITY = 10_000;
export async function onPostCreated({ postId, authorId, createdAt, audience }) {
const author = await users.get(authorId);
if (author.followerCount >= CELEBRITY) {
await redis.zadd(`authored:${authorId}`, createdAt, postId); // pulled at read time
await redis.zremrangebyrank(`authored:${authorId}`, 0, -201);
return;
}
for await (const ids of audienceInPages(authorId, audience, 1000)) { // friends or followers
const p = redis.pipeline();
for (const uid of ids) {
p.zadd(`feed:${uid}`, createdAt, postId);
p.zremrangebyrank(`feed:${uid}`, 0, -801); // cap at 800 entries
}
await p.exec();
}
}
Two refinements matter in production.
- Fan out only to active users. Someone who has not opened the app for 30 days gets their feed rebuilt on their next login instead of receiving every write. That cuts fanout volume by the share of inactive users, often 50% or more of MAU.
- Shard the fanout queue by author. Then a single page with millions of followers cannot hold up everyone else's posts.
3. Ranking pipeline
A reverse-chronological feed is the baseline the clone ships with. A ranked feed is what keeps people coming back. The pipeline runs per request in about 100–150ms.
-
Candidate generation (about 500 items). Combine the pushed feed list, recent posts from followed high-follower accounts, posts from joined groups, and a small slice of "friends engaged with this" candidates. Remove posts the user has already seen (a per-user Bloom filter of seen IDs in Valkey, about 1.2 KB for 1,000 IDs at a 1% false-positive rate), hidden posts, blocked authors and posts outside the audience.
-
Feature fetch. Gather features for each candidate:
- Viewer-author affinity: recent interactions decayed with a 14-day half-life, stored on
edges.affinityand refreshed hourly. - Post age.
- Media type, and whether the viewer tends to engage with video or photos.
- Early engagement velocity: reactions and comments in the first 30 minutes, normalised by audience size.
- Author quality signals.
- Negative signals: hides and "see less".
These come from a feature store: Valkey hashes for the online features, written by stream processors.
- Viewer-author affinity: recent interactions decayed with a 14-day half-life, stored on
-
Scoring. Use a lightweight model, such as a gradient-boosted tree (LightGBM) or a two-layer MLP. It predicts P(react), P(comment), P(share) and P(hide), and combines them with tunable weights:
score = 1.0·P(react) + 4·P(comment) + 6·P(share) − 20·P(hide), multiplied by a freshness decay. With a few hundred trees, scoring 500 candidates takes a few milliseconds on CPU. No GPU is needed at this scale. -
Re-ranking and diversity. At most two posts in a row from the same author, alternate media types, put a fresh friend post near the top, and keep ads or suggestions at fixed slots.
-
Logging. Record impressions and outcomes to the event stream. That data is both the next day's training set and the source for "seen" filtering.
Start with a hand-tuned linear score (affinity × decay × engagement). It beats chronological order. Move to a learned model once you have a few weeks of impression logs, and always keep a chronological "Most recent" view as a user option and a fallback.
4. Counters at scale
A viral post receives thousands of reactions a second. If every reaction runs UPDATE post_counters SET likes = likes + 1, they all contend for one row lock. The pattern is:
- The write path is idempotent and does not touch the counter.
INSERT ... ON CONFLICT (post_id, user_id) DO UPDATE SET type = EXCLUDED.typereturns whether the row was new, so the service knows whether the total changes or only the per-type breakdown. - Deltas go to Valkey.
HINCRBY counters:{postId} likes 1is atomic, and the UI reads it directly. - A rollup worker flushes deltas to Postgres every few seconds in batches, with one
UPDATEper post per flush regardless of how many reactions arrived. - Nightly reconciliation recomputes exact counts with
count(*)for posts touched that day and corrects any drift from crashes.
Show approximate numbers above 1,000 ("1.2K"), which hides the brief inconsistency between replicas and caches. The clone shows the same optimistic behaviour: the count updates immediately and "You and 127 others" appears while the PATCH is still in flight.
5. Media pipeline for photos
- Presigned upload. The client asks
POST /v1/media/uploadsand gets a presigned POST to Object Storage with a content-length range (up to 20 MB) and an allowed content type. The bytes never pass through the app servers. The clone downscales on a canvas instead, which is a good addition anyway because it cuts upload time on mobile. - Processing. An upload event triggers a worker that:
- checks the magic bytes (not the extension),
- strips EXIF (location data) but keeps orientation,
- computes a perceptual hash for duplicate and abuse matching,
- generates renditions at 320, 720, 1080 and 2048px wide in AVIF (quality about 50) and WebP (quality about 75), plus a JPEG fallback,
- computes a BlurHash placeholder.
- Storage. Renditions are written to content-addressed keys (
img/ab/cd/<sha256>-1080.avif). The post'smediaJSON stores the key, dimensions and BlurHash, so the feed can reserve the right aspect ratio before the image loads and avoid layout shift. - Delivery. The CDN pulls from Object Storage.
<picture>orsrcsetwithsizes="(max-width: 680px) 100vw, 680px"lets a phone download the 720px AVIF (often 60–90 KB) instead of a 3 MB original. With a 95% CDN hit ratio, origin egress at 576 TB/month of delivery is about 29 TB. - Moderation hooks. Hash matching against known-abuse lists and an image classifier run before the post becomes visible to anyone but its author.
6. Search
People search and content search are different problems.
- People search is prefix-based and personalised. Index name, handle and city, then rank first-degree friends above friends-of-friends above everyone else, adjusted by mutual-friend count. A typeahead index per user's two-hop network is too expensive at scale. Instead, query the global index for the prefix, then re-rank the top 200 with the viewer's friend set (a Valkey set intersection).
- Content search covers posts, groups and videos. It is full-text search with BM25, filtered by audience: a friends-only post must only match for that author's friends. That is enforced as a filter on an
allowed_viewersfield for small audiences, plus a post-filter check. - Indexing comes from the event stream (
post.created,post.deleted,edge.changed), so search is seconds behind the database, never minutes. - Engine. Meilisearch works well up to a few million documents on one node. Move to OpenSearch when you need sharding, per-field analyzers for many languages, and better control over relevance. The clone's word-prefix matcher in
lib/db.jsis the toy version of the same contract.
Security, privacy and abuse
Threat model and mitigations drafted with Claude in Claude Code.
Authentication and sessions
- Sign-in. Use OIDC (Google or Apple) and passkeys (WebAuthn) as the primary methods. Email magic links are the fallback, and passwords are optional and hashed with Argon2id.
- Sessions. Use server-side sessions in Valkey, with an opaque 256-bit ID in an
HttpOnly; Secure; SameSite=Laxcookie. Avoid long-lived JWTs for a social app. You need instant revocation when an account is compromised, and a JWT can only be revoked by building a denylist, which recreates sessions anyway. - Step-up re-authentication is required for changing email, adding a login method and downloading account data.
Authorization
Every read checks audience. A friends-only post requires the viewer to have a friend edge with the author, and a group post requires membership. The check happens when the post is hydrated, not only when the feed is built, because edges change after fanout. Keep this in one canView(viewer, post) function that has unit tests for every audience and block combination. Most privacy bugs in social apps are an IDOR on one endpoint that skipped this check.
OWASP Top 10, applied to this app
- Injection. All SQL is parameterised. Search input goes to the search engine as a query string, never concatenated into a DSL.
- XSS. Post bodies, comments, names and chat messages are rendered as text by React. There is no
dangerouslySetInnerHTML. Link previews are sanitised on the server. - CSRF. SameSite cookies, plus a double-submit token on state-changing requests and an
Origincheck in middleware. - Broken access control. The
canViewandcanEditchecks described above, plus rate limits on ID-enumeration patterns. - SSRF. The link-preview fetcher runs in an isolated worker with an egress allow-list. It blocks private IP ranges, including Hetzner's private network ranges and the metadata address.
Headers and CSP
Use a strict, nonce-based Content Security Policy:
default-src 'self'img-src 'self' data: blob: https://cdn.kinfolk.example https://i.ytimg.comframe-src https://www.youtube-nocookie.comconnect-src 'self' wss://rt.kinfolk.example
Add Strict-Transport-Security (with preload), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that allows the camera only on the live-video route.
Spam, abuse and moderation
- Account trust. New accounts get lower rate limits, cannot send messages to non-friends for 24 hours, and their links are held for scanning.
- Friend-request spam is detected by acceptance rate. An account whose requests are mostly ignored or rejected is throttled.
- Uploads. Uploads are type-checked by magic bytes, scanned with ClamAV, hash-matched against known-abuse databases, and classified before they become public. Reports go to a review queue with SLA tracking.
- Coordinated abuse shows up in graph features: many new accounts with the same device fingerprint befriending the same targets.
Secrets, encryption and infrastructure
- Secrets. Store them in SOPS-encrypted files or a Vault instance, delivered to k3s as Secrets. Nothing goes in images or Git.
- Encryption. TLS 1.3 at the edge, and TLS or WireGuard between nodes on the private network. Disks are encrypted with LUKS on dedicated servers. Object Storage server-side encryption is enabled, and backups are encrypted by pgBackRest with a separate key.
- DDoS. Hetzner's automatic DDoS protection handles volumetric attacks, and the CDN's WAF handles layer 7: bot scoring and challenge pages on login and sign-up.
GDPR and India's DPDP Act
- Data export. Offer a "download your information" job that builds a ZIP from every table keyed by user ID.
- Deletion. Account deletion is a soft delete with a 30-day grace period, then a hard delete that cascades across shards, purges media and expires CDN objects.
- Hosting and contracts. Keep EU users' primary data in Germany or Finland. Record the lawful basis for each processing purpose, and sign DPAs with the CDN and push providers.
- Audit log. An append-only log records administrative actions, logins from new devices, and every moderation decision.
Observability and operations
Telemetry
- Traces. Instrument the Next.js server, API, workers and gateway with OpenTelemetry. Traces go to Tempo, and every request carries a trace ID through NATS headers into the workers. A slow feed load can then be followed from the HTTP request into the fanout lag that caused it.
- Metrics. Prometheus scrapes node_exporter, postgres_exporter, the Valkey exporter, the NATS
/varzendpoint and the app's own metrics. Dashboards and alerts live in Grafana. - Logs. Structured JSON logs go to Loki, with user IDs hashed. Request bodies are never logged.
SLOs that matter for a feed
| SLI | Target |
|---|---|
| Feed API p95 latency | < 300 ms |
| Feed API availability | 99.9% monthly |
| Post visible to friends (fanout lag) p95 | < 5 s |
| Chat message delivery p95 (both online) | < 500 ms |
| Notification push sent p95 | < 30 s |
| Image first byte from CDN p95 | < 150 ms in EU |
Alert on burn rate of the error budget (a fast window and a slow window), not on raw thresholds. A 2% error spike for five minutes pages someone. A 0.2% elevation for six hours opens a ticket. Product-specific alerts include fanout queue depth and age in JetStream, counter-flush lag, the replica lag that feed reads depend on, the gateway connection count dropping suddenly (usually a bad deploy), and the push delivery failure rate.
Load testing
Use k6 with realistic mixes: 80% feed reads, 12% reactions, 5% comments, 2% posts and 1% search. Add a separate WebSocket scenario that holds 50K connections per gateway node. Run it against a staging cluster built from the same Ansible and Helm code. The key test is a "celebrity post": one account with a million followers posting while normal traffic continues. It shows whether the hybrid fanout keeps everyone else's latency flat.
Chaos and restore drills
- Monthly: kill the Postgres primary in staging and confirm Patroni fails over in under 30 seconds with no lost committed transactions.
- Monthly: restore last night's pgBackRest backup to a fresh instance and diff the row counts.
- Quarterly: drop a whole Valkey node and verify feeds rebuild from Postgres without an outage.
CI/CD
- CI. GitHub Actions runs lint, unit tests and Playwright smoke tests (post, react, comment, chat) on each pull request, then builds a container image tagged with the commit SHA.
- Deploys. Argo CD (or Flux) syncs Helm charts to k3s. Canary releases go to 5% of traffic using ingress weights, with automatic rollback when the error-rate SLO burns.
- Database migrations follow expand-and-contract (add the column, backfill, switch reads, drop later). They run as a separate job, never at app start.
Cost breakdown
Cost model calculated by Claude Code from the Hetzner line items above, excl. VAT.
Monthly infrastructure estimates per tier, built from the server lists in this guide. EUR excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering. Load balancer prices are approximate ("from ~€6/month, check console"). CDN costs are separate and depend on the provider.
| Tier | Users | Hetzner infra / month | Infra per 1K MAU (at the top of the tier) | Biggest line |
|---|---|---|---|---|
| Launch | 0–50K MAU | €254.43 | €5.09 | PostgreSQL primary + standby (+PgBouncer) (€146.98) |
| Growth | ~500K MAU | €1,299.28 | €2.60 | PostgreSQL (Patroni: primary + 2 replicas) (€353.97) |
| Scale | ~5M MAU | €3,337.43 | €0.67 | Object Storage (~150 TB media) (€958.99) |
| Hyperscale | 10M+ MAU | €10,333.35 | €1.03 | Object Storage (~500 TB media) (€3,181.49) |
CDN egress is extra: estimated at ~€2,900/month at 10M MAU under the assumed €0.005/GB rate. Add it to the Hyperscale line for a fuller picture. Setup fees for dedicated servers are one-off.
Main cost drivers
- Image egress. At 10M MAU the estimate is about 576 TB a month delivered (see the sizing table). This is a CDN cost, not a Hetzner one. At an assumed blended CDN rate of €0.005/GB it is about €2,900/month. CDN pricing varies widely by provider, region and commitment, so get quotes. Hetzner's role is origin: 1 TB of Object Storage egress is included and additional egress is about €1/TB. With a 95% cache hit ratio, cache fill is about 29 TB, roughly €30.
- Photo storage grows without limit. About 5.4 TB/month of new photos at roughly €6.35/TB-month adds about €34/month to the bill every month, which is about €2,000/month after five years.
- RAM for feed caches. 800 IDs × 10 bytes × 4M active users is about 32 GB of payload. Valkey's sorted set overhead roughly triples that, so plan for about 100 GB across the cluster plus replicas.
- People. At every tier, the on-call engineers cost more than the servers. Running Postgres yourself is the main operational cost that Hetzner does not take off your hands.
How to cut costs
- Serve AVIF at the right width. Going from a 1080px JPEG (about 250 KB) to a 720px AVIF (about 70 KB) on phones cuts image bytes by over 70%.
- Delete originals after 90 days if your product does not need them. Keep the 2048px rendition as the master copy.
- Fan out only to users active in the last 30 days.
- Stop pushing presence to contacts that are not on screen.
- Move steady-state compute to AX servers once utilisation is predictable. Keep cloud instances for bursty workers and new regions.
- Prefer Hetzner's EU locations. Its US cloud plans include far less traffic (1–5 TB, compared with 20 TB in the EU) and cost more for the same specs.
Roadmap: from clone to launch
The clone covers the product surface. These are the concrete steps to a production launch, in order.
Foundation (weeks 1–4)
- PostgreSQL schema from this guide, with migrations (for example node-pg-migrate) and seed scripts
- Auth: OIDC plus passkeys, Valkey sessions, CSRF middleware, account deletion and export
- Replace in-memory route handlers with services. Keep the same response shapes so the UI does not change
- Presigned uploads to Object Storage and a sharp worker for AVIF/WebP renditions and BlurHash
-
canViewauthorization module with exhaustive tests
Core feed (weeks 5–8)
- NATS JetStream with a
post.createdfanout worker, Valkey feed lists, cold-start rebuild - Hybrid read path for high-follower pages
- Reaction upsert plus Valkey counter deltas and a rollup worker, with nightly reconciliation
- Keyset pagination and a signed feed cursor
- Notifications table, aggregation and in-app inbox
Realtime (weeks 9–11)
- WebSocket gateway, per-conversation sequence numbers, client IDs, replay on reconnect
- Presence heartbeats, typing events, read receipts as watermarks
- Web Push (VAPID) service worker, with APNs/FCM for the mobile apps
Trust and operations (weeks 12–14)
- Rate limits, new-account restrictions, report flow and moderation queue
- Upload scanning and hash matching
- OpenTelemetry, Grafana dashboards, SLO alerts, k6 baseline including a celebrity-post scenario
- pgBackRest to Object Storage, first restore drill, Patroni failover drill
Launch and after
- CDN in front of media with immutable keys, and a strict CSP with nonces
- Launch on the Launch-tier topology, then add read replicas and k3s as traffic arrives
- Log impressions, then ship the first learned ranking model behind an experiment flag
- Search on Meilisearch, with a plan to move to OpenSearch
Implementation snippets
Idempotent reaction upsert
One row per (post, user). The insert tells you whether this was a new reaction (the total changes) or a switch from Like to Love (only the breakdown changes). Repeating the same reaction does nothing and returns no row.
-- set or change my reaction
INSERT INTO reactions (post_id, user_id, type)
VALUES ($1, $2, $3)
ON CONFLICT (post_id, user_id) DO UPDATE
SET type = EXCLUDED.type
WHERE reactions.type IS DISTINCT FROM EXCLUDED.type
RETURNING (xmax = 0) AS inserted; -- true = new row, false = type changed, no row = no-op
-- clear my reaction; the returned type tells the counter worker what to decrement
DELETE FROM reactions
WHERE post_id = $1 AND user_id = $2
RETURNING type;Fanout-on-write worker with a celebrity threshold
Consumes post.created events. Normal authors are pushed into each follower's capped Valkey sorted set. High-follower authors are only indexed for pulling at read time, so one big page cannot flood the fanout queue.
import Redis from "ioredis";
const redis = new Redis.Cluster(JSON.parse(process.env.VALKEY_NODES));
const CELEBRITY = 10_000;
const FEED_CAP = 800;
export async function onPostCreated({ postId, authorId, createdAt }, { users, audienceInPages }) {
const author = await users.get(authorId);
if (author.followerCount >= CELEBRITY) {
await redis.zadd(`authored:${authorId}`, createdAt, postId);
await redis.zremrangebyrank(`authored:${authorId}`, 0, -201); // keep the newest 200
return { mode: "pull" };
}
let n = 0;
// only users active in the last 30 days; inactive feeds are rebuilt on next login
for await (const ids of audienceInPages(authorId, { pageSize: 1000, activeWithinDays: 30 })) {
const p = redis.pipeline();
for (const uid of ids) {
p.zadd(`feed:${uid}`, createdAt, postId);
p.zremrangebyrank(`feed:${uid}`, 0, -(FEED_CAP + 1));
}
await p.exec();
n += ids.length;
}
return { mode: "push", recipients: n };
}Hybrid feed read: merge pushed and pulled candidates
Reads the user's pushed list and the recent posts of the few high-follower accounts they follow, removes posts already seen, and passes about 500 candidates to the ranker.
export async function feedCandidates(viewerId, { redis, followedCelebrities, seenFilter }) {
const [pushed, celebs] = await Promise.all([
redis.zrevrange(`feed:${viewerId}`, 0, 399, "WITHSCORES"),
followedCelebrities(viewerId), // cached Valkey set, usually < 50 ids
]);
const p = redis.pipeline();
for (const c of celebs) p.zrevrange(`authored:${c}`, 0, 19, "WITHSCORES");
const pulled = (await p.exec()).flatMap(([err, res]) => (err ? [] : res));
const byId = new Map();
for (const list of [pushed, pulled]) {
for (let i = 0; i < list.length; i += 2) byId.set(list[i], Number(list[i + 1]));
}
const seen = await seenFilter.containsMany(viewerId, [...byId.keys()]); // Bloom filter
return [...byId]
.filter(([id], i) => !seen[i])
.sort((a, b) => b[1] - a[1])
.slice(0, 500)
.map(([postId, ts]) => ({ postId, ts }));
}Counter deltas in Valkey, flushed to Postgres in batches
Reactions increment a Valkey hash, which the UI reads directly. Every two seconds a worker drains the dirty set and writes one upsert per batch, so a viral post costs one row update per flush instead of one per reaction.
import Redis from "ioredis";
import pg from "pg";
const redis = new Redis(process.env.VALKEY_URL);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
export async function bump(postId, field, by = 1) {
await redis.multi().hincrby(`delta:${postId}`, field, by).sadd("counters:dirty", postId).exec();
}
async function flush() {
const ids = await redis.spop("counters:dirty", 5000);
if (!ids.length) return;
const tx = redis.multi();
for (const id of ids) tx.hgetall(`delta:${id}`).del(`delta:${id}`);
const res = await tx.exec();
const rows = ids.map((id, i) => ({ id, d: res[i * 2][1] || {} }));
await pool.query(
`INSERT INTO post_counters (post_id, likes, comments, shares)
SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::bigint[], $4::bigint[])
ON CONFLICT (post_id) DO UPDATE SET
likes = post_counters.likes + EXCLUDED.likes,
comments = post_counters.comments + EXCLUDED.comments,
shares = post_counters.shares + EXCLUDED.shares`,
[rows.map((r) => r.id), rows.map((r) => +r.d.likes || 0), rows.map((r) => +r.d.comments || 0), rows.map((r) => +r.d.shares || 0)]
);
}
setInterval(() => flush().catch((e) => console.error("flush failed", e)), 2000);Photo rendition worker (AVIF + WebP) with sharp
Runs on each media.uploaded event. It validates the real format, applies the EXIF orientation and drops the rest of the metadata (sharp does not keep it unless asked), then writes content-addressed renditions to Hetzner Object Storage for the CDN to pull.
import sharp from "sharp";
import { createHash } from "node:crypto";
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "fsn1",
endpoint: "https://fsn1.your-objectstorage.com",
credentials: { accessKeyId: process.env.S3_KEY, secretAccessKey: process.env.S3_SECRET },
});
const WIDTHS = [320, 720, 1080, 2048];
const FORMATS = [["avif", { quality: 50, effort: 4 }], ["webp", { quality: 75 }]];
export async function processUpload(key) {
const obj = await s3.send(new GetObjectCommand({ Bucket: "kinfolk-uploads", Key: key }));
const input = Buffer.from(await obj.Body.transformToByteArray());
const meta = await sharp(input).metadata();
if (!["jpeg", "png", "webp", "heif", "avif"].includes(meta.format)) throw new Error(`rejected ${meta.format}`);
const hash = createHash("sha256").update(input).digest("hex");
const base = sharp(input, { failOn: "error" }).rotate(); // apply orientation; metadata stripped on output
const renditions = [];
for (const w of WIDTHS) {
if (w > (meta.width || 0) && w !== 320) continue;
for (const [fmt, opts] of FORMATS) {
const body = await base.clone().resize({ width: w, withoutEnlargement: true }).toFormat(fmt, opts).toBuffer();
const outKey = `img/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}-${w}.${fmt}`;
await s3.send(new PutObjectCommand({
Bucket: "kinfolk-media", Key: outKey, Body: body,
ContentType: `image/${fmt}`, CacheControl: "public, max-age=31536000, immutable",
}));
renditions.push({ key: outKey, width: w, format: fmt, bytes: body.length });
}
}
return { hash, width: meta.width, height: meta.height, renditions };
}Presence with Valkey TTLs
Each connected device refreshes a 60-second key every 30 seconds. The contacts list asks only for the people on screen, in one round trip, instead of every status change being pushed to every friend.
// gateway: called on connect and every 30 s while any socket is open
export async function heartbeat(redis, userId) {
await redis.set(`presence:${userId}`, Date.now(), "EX", 60);
}
// gateway: last socket closed
export async function wentAway(redis, userId) {
await redis.set(`lastseen:${userId}`, Date.now(), "EX", 60 * 60 * 24 * 30);
}
// API: GET /v1/presence?ids=... for the ~30 visible contacts
export async function presenceFor(redis, ids) {
const p = redis.pipeline();
p.mget(ids.map((id) => `presence:${id}`));
p.mget(ids.map((id) => `lastseen:${id}`));
const [[, online], [, seen]] = await p.exec();
return Object.fromEntries(ids.map((id, i) => [id, online[i] ? { online: true } : { online: false, lastSeen: seen[i] ? Number(seen[i]) : null }]));
}pgBackRest to Hetzner Object Storage
Continuous WAL archiving and scheduled backups to an S3-compatible bucket. Internal traffic and API calls to Object Storage are free, so frequent archiving costs nothing extra.
sudo tee /etc/pgbackrest/pgbackrest.conf >/dev/null <<'CONF'
[global]
repo1-type=s3
repo1-s3-endpoint=fsn1.your-objectstorage.com
repo1-s3-region=fsn1
repo1-s3-bucket=kinfolk-pg-backups
repo1-s3-key=REPLACE_ME
repo1-s3-key-secret=REPLACE_ME
repo1-path=/pgbackrest
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=REPLACE_ME
compress-type=zst
process-max=4
start-fast=y
[main]
pg1-path=/var/lib/postgresql/17/main
CONF
# postgresql.conf: archive_mode = on
# archive_command = 'pgbackrest --stanza=main archive-push %p'
sudo -u postgres pgbackrest --stanza=main stanza-create
sudo -u postgres pgbackrest --stanza=main check
# cron: full on Sunday, differential every other night
# 0 2 * * 0 postgres pgbackrest --stanza=main --type=full backup
# 0 2 * * 1-6 postgres pgbackrest --stanza=main --type=diff backupk3s web tier: autoscaling behind a Hetzner Load Balancer
The Hetzner cloud controller manager turns a LoadBalancer Service into a Hetzner LB that reaches nodes over the private network. The HPA scales the stateless Next.js pods on CPU.
apiVersion: v1
kind: Service
metadata:
name: kinfolk-web
annotations:
load-balancer.hetzner.cloud/location: fsn1
load-balancer.hetzner.cloud/type: lb11
load-balancer.hetzner.cloud/use-private-ip: "true"
spec:
type: LoadBalancer
selector: { app: kinfolk-web }
ports:
- { name: http, port: 80, targetPort: 3000 }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: kinfolk-web
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: kinfolk-web }
minReplicas: 4
maxReplicas: 40
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 65 } }
behavior:
scaleDown: { stabilizationWindowSeconds: 300 }k6 load test with a realistic social mix
About 80% feed reads, 12% reactions, 5% comments, 2% posts and 1% search, ramped to the estimated peak of about 3,300 feed requests per second, with SLO thresholds that fail the run.
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
scenarios: {
social: {
executor: "ramping-arrival-rate",
startRate: 200, timeUnit: "1s", preAllocatedVUs: 1000, maxVUs: 6000,
stages: [{ target: 1500, duration: "5m" }, { target: 4100, duration: "10m" }, { target: 0, duration: "2m" }],
},
},
thresholds: {
http_req_failed: ["rate<0.01"],
"http_req_duration{name:feed}": ["p(95)<300"],
"http_req_duration{name:react}": ["p(95)<150"],
},
};
const BASE = __ENV.BASE_URL;
const params = (name) => ({ tags: { name }, headers: { cookie: __ENV.SESSION, "content-type": "application/json" } });
const post = () => String(1 + Math.floor(Math.random() * 100000));
export default function () {
const r = Math.random();
if (r < 0.8) check(http.get(`${BASE}/v1/feed`, params("feed")), { "feed 200": (res) => res.status === 200 });
else if (r < 0.92) http.put(`${BASE}/v1/posts/${post()}/reaction`, JSON.stringify({ type: 1 }), params("react"));
else if (r < 0.97) http.post(`${BASE}/v1/posts/${post()}/comments`, JSON.stringify({ body: "load test" }), params("comment"));
else if (r < 0.99) http.post(`${BASE}/v1/posts`, JSON.stringify({ body: "k6", audience: 2 }), params("post"));
else http.get(`${BASE}/v1/search?q=priya`, params("search"));
sleep(0.2);
}Related videos from The Bhairav Show
Frequently asked questions
How did you build the Facebook clone with Claude Code?
I designed Kinfolk with Claude on a design canvas, then gave Claude Code the design and a written brief covering the stack, the fidelity bar, what "fully working" means and the proof required. A Claude Code sub-agent running Claude Opus 5.5 built the app in about 33 minutes: it wrote the Next.js app and API routes, got build and lint passing, ran the app, took and reviewed screenshots, and drafted the scaling plan.
Can Claude Code build a production-ready Facebook clone?
Claude Code can build a complete, working frontend with API routes quickly, as Kinfolk shows. Production readiness also needs the backend described in this guide: a real database, authentication, a real-time gateway, media or payment pipelines, security hardening and operations. Claude Code helps write that too, but the architecture and risk decisions stay with the engineer.
Which Claude tools were used to build this clone?
Claude's design canvas for the screens, claude Code and its sub-agents for coding, testing and screenshots, and Claude Opus 5.5 for the scalability, Hetzner infrastructure, real-time and security analysis.
How do you build a Facebook clone with Next.js?
Start with the App Router for routing and layouts, client components for the interactive feed, and route handlers under app/api for posts, comments, stories, search and chat. Kinfolk does exactly that: a three-column layout, a cursor-paginated feed, reactions, comments, stories and chat, all talking to its own API. For production, replace the in-memory handlers with PostgreSQL, Valkey and a fanout worker, keeping the same response shapes.
Is fanout-on-write or fanout-on-read better for a news feed?
Use both. Fanout-on-write (pushing post IDs into each follower's feed list) makes reads cheap, and reads outnumber writes by about 100 to 1. But it explodes for accounts with millions of followers. A hybrid pushes for normal authors, pulls at read time for authors above a threshold such as 10,000 followers, and merges the two lists before ranking.
How does Facebook rank the news feed?
In outline: generate a few hundred candidate posts from friends, pages and groups, fetch features such as viewer-author affinity, post age, media type and early engagement, then score each candidate with a model that predicts actions like reacting, commenting, sharing and hiding. A diversity pass prevents runs of the same author. A clone can start with a hand-tuned score (affinity × time decay × engagement) and switch to a gradient-boosted model once impression logs exist.
How do you store a social graph in PostgreSQL?
Use a directed edge table keyed by (src_id, kind, dst_id), with friendship stored as two edges, plus an inverse index on (dst_id, kind, src_id) for 'who follows me'. Shard by src_id so each user's outgoing edges sit on one shard, and cache hot adjacency lists in Valkey. You don't need a graph database for one- and two-hop queries.
How do you count likes for viral posts without locking the database?
Don't increment a counter column on every reaction. Insert an idempotent reaction row, increment a Valkey hash for the live count, and have a worker flush deltas to a separate post_counters table in batches every few seconds. A nightly job recomputes exact counts to correct drift. Display rounded numbers like 1.2K above a thousand.
How much does it cost to host a Facebook clone for 1 million users?
Using the tiers in this guide, about 500,000 monthly users fit in roughly €1,300 a month of Hetzner infrastructure (Growth tier), and about 5 million fit in roughly €3,300 on dedicated AX servers (Scale tier). One million users therefore lands in the low thousands of euros a month for servers. CDN egress for images is extra and grows with usage. All figures are estimates, excluding VAT, from hetzner.com in November 2026, and should be verified before ordering.
Can I host a social network on Hetzner?
Yes. Use cloud instances to start, a Cloud Network and Firewall for isolation, Load Balancers, Object Storage for media and backups, and dedicated AX servers once load is steady. The limits to plan around are that there is no managed PostgreSQL, dedicated servers are only in Germany and Finland, and you need a CDN for global latency. DDoS protection is included.
How do online status dots work at scale?
Each connected device refreshes a Valkey key with a 60-second TTL every 30 seconds. The contacts list asks only for the people currently on screen in a single MGET, and subscribes to changes for those users only, so status changes are not pushed to every friend. A short grace period before showing someone as offline prevents flicker on flaky mobile connections.
How do chat apps keep messages in order and avoid duplicates?
Assign each message a gapless sequence number per conversation in the same transaction as the insert, and render by sequence rather than arrival time. The device generates a client ID before sending, so a retried request hits a unique constraint and returns the existing message. On reconnect, the client sends its highest sequence number and the server replays anything newer.
Should I use WebSockets or Server-Sent Events for notifications?
Use WebSockets for chat, because typing indicators, read receipts and sends flow both ways. Server-Sent Events are simpler for one-way updates like notification badges and live counters, work through proxies and resume automatically with Last-Event-ID. Offline users are reached with Web Push (VAPID) in browsers and APNs/FCM on mobile.
How should a social app process uploaded photos?
Upload directly to object storage with a presigned URL. A worker then checks the real file type, strips EXIF location data while keeping orientation, and generates AVIF and WebP renditions at several widths plus a BlurHash placeholder. Serve the renditions from a CDN with srcset, so phones download a 60–90 KB file instead of a multi-megabyte original.
How do you add search to a social network?
Index people, posts and groups from the event stream into Meilisearch, or OpenSearch at larger scale, so the index is seconds behind the database. Rank people by social distance (friends first, then friends of friends, by mutual count) and filter content by audience so friends-only posts never leak. Kinfolk's demo search matches on word prefixes over people, groups, videos and posts.
Is this Facebook clone affiliated with Meta?
No. Kinfolk is an independent educational project that recreates the general patterns of a social news feed. It uses its own name and design, contains no Meta branding, and uses fictional demo people. The only real media is Suhas Bhairav's own YouTube content.
More SaaS clones
- YouTube cloneHow I Built a YouTube Clone Using Claude Code: Viewly
- ChatGPT cloneHow I Built a ChatGPT Clone Using Claude Code: Assist
- Airbnb cloneHow I Built an Airbnb Clone Using Claude Code: nestly
- TikTok cloneHow I Built a TikTok Clone Using Claude Code: Loop
- Snapchat cloneHow I Built a Snapchat Clone Using Claude Code: Glint






