Snapchat cloneEphemeral camera messaging

How I Built a Snapchat Clone Using Claude Code: Glint

Designed with Claude, coded and tested with Claude Code, and planned for millions of users with Claude Opus 5.5: how I built Glint, a working Snapchat-style app.

By Suhas Bhairav · Published November 11, 2026 · 46 min read

Glint chat inbox with New Snap, New Chat, Opened, Received and Delivered statuses, story rings and flame streak counts

Quick answer

Build the inbox around a forward-only receipt state machine. Encrypt with the Signal protocol (MLS for groups) and give every Snap its own key. Upload ciphertext to object storage with TTL lifecycle rules and delete it on open. Deliver through per-device NATS JetStream queues behind WebSocket gateways, using APNs/FCM for offline devices, and shard PostgreSQL by conversation.

  • Built with Claude Code in about 31 minutes of agent time: 41 files, 6 pages and 10 API routes, with build and lint passing.
  • The product is a receipt state machine: sending, sent, delivered, opened, screenshotted, moving forward only, rendered from one status table.
  • Use libsignal (X3DH/PQXDH + Double Ratchet) for 1:1 chats and MLS via OpenMLS for groups. The server sees ciphertext and metadata only.
  • Give every Snap its own AES-256-GCM key. Delete the blob on last open, cap lifetime with object-storage lifecycle rules, and alert on overdue deletions.
  • Fan out on write into per-device NATS JetStream queues. That queue is also the offline inbox, and acks delete envelopes.
  • Plan ~150K WebSockets per 8-core dedicated server. At ~5M MAU (estimate) that is six gateways and roughly 7 Gbit/s of peak media egress.
  • Estimated Hetzner infrastructure: €176.44/month at launch, about €3,389.14/month at ~5M MAU, with media egress the biggest line.
  • Location sharing must be opt-in, coarse by default, encrypted per friend and short-lived. Ghost Mode should stop uploads, not just hide markers.
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 Glint: 41 files, 6 pages, 10 API routes, in about 31 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 10 screenshots that Claude reviewed and fixed.
  • Scalability & infrastructure analysisClaude Opus 5.5Capacity math, Hetzner server plans, real-time design, security and cost models.
Run it locally
git clone https://github.com/suhasbhairav/snapchat-clone.git && cd snapchat-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 v4Inter via next/fontMediaDevices getUserMedia + Canvas 2DIntersectionObserver + CSS scroll-snapNext.js Route Handlers (in-memory demo API)localStorage persistenceYouTube nocookie embeds
Palette
Camera yellow #FFFC00Chat blue #0EADFFSnap red #F23C57Video Snap purple #A05DCDInk #000000Chip grey #F2F2F2Muted text #6E6E6EBackground #FFFFFF

Screenshots of Glint

Glint chat inbox with New Snap, New Chat, Opened, Received and Delivered statuses, story rings and flame streak counts
The chat inbox from the design: filter chips, status squares, relative times, flame streaks, unread badge and the blue new-chat button.
Glint chat thread with Yesterday and Today separators, saved messages and a typing indicator after sending a message
A thread right after sending: the message shows Opened, Priya is typing, and a simulated reply arrives a moment later. Saved messages are grey.
Full-screen Snap viewer with a countdown ring, a caption bar and a Reply in chat button
Opening a received Snap: a countdown ring drains, hold pauses, tap skips, and the Snap is marked Opened and cannot be replayed.
Glint camera with live preview, flip and flash buttons and a lens carousel above the shutter
The live camera (Chrome fake device) with the Dream lens: the CSS filter and overlay are applied live and replayed on a canvas at capture.
Send To sheet with My Story, friends sorted by streak and a blue send bar with the selected recipient
Send To: My Story plus friends and groups sorted by streak. Sending flips each row to Delivered, then Opened.
Stories screen with friends' story rings, The Bhairav Show episode cards and a Discover grid of YouTube shorts
Stories: friends' rings, Subscriptions with The Bhairav Show episodes, and a Discover grid of Suhas Bhairav's shorts that play in a nocookie embed.
Spotlight vertical short-video feed playing a Suhas Bhairav short with like, send, share and YouTube buttons
Spotlight: a scroll-snap feed where only the visible short mounts an iframe. Like, double-tap, share and Watch on YouTube all work.
Stylised friend map with avatars, a selected friend card with Chat and Snap buttons and a ghost mode button
The friend map: pan and zoom an SVG city, tap a friend for Chat or Snap, and toggle Ghost Mode to hide your location.
Profile sheet with avatar, Glint Score, friend and streak counters, streak list and settings toggles
The profile sheet: editable name, score, streak list, Ghost Mode, notifications and the default delete-after-viewing setting.
Glint shown as a centred phone-sized column on a desktop browser
On desktop the mobile app renders in a centred 390×844 phone column on a neutral backdrop.

What works in the clone

  • Chat inbox matching the design

    Header, filter chips (All, Unread, Groups, Stories), eight status kinds rendered from a table ported from the design's renderVals(), relative times, story rings and an unread badge on the Chat tab. Rows are bold when unread and open the Snap viewer directly when a Snap is pending.

  • Delivery state machine

    Sending flips a row to Delivered, then Opened when the simulated friend reads it, then typing, then New Chat or Received. Snaps show a red Delivered, then Opened. Implemented as chained client timers in GlintProvider calling POST /api/chat/reply.

  • Delete after viewing or 24 hours

    Per-chat policy toggle in the thread. 'After viewing' removes messages both sides have seen when you leave the chat (deferred so StrictMode remounts are safe). '24h' is purged by a one-minute interval. Press and hold saves a message.

  • Snap viewer with countdown

    A full-screen viewer shared by the inbox (/?snap=chatId) and the thread (?snap=msgId). Countdown ring, hold to pause, tap to skip, mark opened on render, no replay. Video Snaps play a channel short through youtube-nocookie.

  • Live camera with lenses

    getUserMedia with front/back flip, screen flash or torch constraint, and nine lenses (CSS filter plus overlay) applied live and replayed on a 540×960 canvas at capture with ctx.filter. Falls back to a sample photo or upload when permission is denied.

  • Caption, timer and Send To

    Tap the photo to place a caption bar, cycle 3/5/10 s view time, download, post to My Story, or pick friends and groups (sorted by streak) in the Send To sheet. POST /api/snaps returns one receipt per chat.

  • Streaks with SVG flame

    Streak counts render with an inline SVG flame (no emoji). A Snap coming back on a day you snapped increments the streak once per day. The profile sheet lists every streak.

  • Search, Add friends and new chats

    Search covers friends, groups and message text locally, plus /api/search for new people. Add friends handles Added Me requests and Quick Add with pending then auto-accept. The pencil FAB creates 1:1 or named group chats via POST /api/chats.

  • Stories and Discover from The Bhairav Show

    Friends' stories with segmented progress bars, tap and hold controls and reply-to-chat. Discover and Subscriptions are built from Suhas Bhairav's channel: thumbnails first, then a nocookie embed with Watch on YouTube.

  • Spotlight feed

    A vertical scroll-snap feed where an IntersectionObserver mounts only the active iframe, with cursor pagination from /api/spotlight, like (persisted and posted), double-tap like, Web Share or clipboard share, and a YouTube link.

  • Friend map with Ghost Mode

    SVG city with friend avatars from /api/map, drag to pan, wheel or button zoom, recenter, a friend card with Chat and Snap, and a Ghost Mode switch that hides your marker.

  • Profile sheet and persistence

    Editable display name, username, score, counters, streak list and settings. All state persists in localStorage (try/catch wrapped) and is seeded from /api/bootstrap on first visit.

Overview: what Glint is and what it proves

Glint is a camera-first messaging app. It covers the product category Snapchat made popular, but it uses its own name, palette and data. I built it from a single 390×844 design file. That file shows the Chat inbox: a yellow-ringed profile avatar, search, an "Add friends" button and an overflow menu, the filter chips All / Unread / Groups / Stories, eight chat rows with coloured status squares, a blue pencil FAB and a five-tab bottom bar with a yellow camera button in the middle. The palette comes straight from the design tokens: camera yellow #FFFC00, chat blue #0EADFF, Snap red #F23C57 and video-Snap purple #A05DCD, with Inter for type.

The inbox matches the design pixel for pixel. Everything else is built from the same visual language: the chat thread, the full-screen Snap viewer, the camera with lenses, the Send To sheet, Stories with a Discover section, the Spotlight short-video feed, the friend map and the profile sheet. The streak column was an emoji in the design. Here it is an inline SVG flame next to the day count, because the brief bans emoji as icons.

What is real

  • Every control works. Filter chips filter. Search hits both local chats and a server people-search endpoint. Add friends sends a request that is accepted a few seconds later. The FAB creates 1:1 or group chats through POST /api/chats. The More menu marks everything read, clears conversations and resets the demo.
  • The status state machine is the core of this product and it is implemented end to end. A text you send shows Delivered (blue outline), then Opened when the simulated friend reads it, then the friend starts typing, then a reply arrives as New Chat (filled blue) or Received if you are looking at the thread. A Snap you send shows Delivered in red, flips to Opened a few seconds later, and sometimes a Snap comes back, which extends the streak.
  • Ephemerality. Each chat has a delete policy: after viewing (messages both sides have seen disappear when you leave the thread) or 24 hours after viewing (a timer purges them). Press and hold to save a message, exactly like the real thing. Received Snaps open in a full-screen viewer with a countdown ring, are marked opened the moment they are shown, and cannot be replayed.
  • The camera is live. getUserMedia with front/back flip, a flash (screen flash for the front camera, torch constraint for the rear one where the hardware supports it) and nine lenses. Each lens is a CSS filter plus an optional gradient overlay applied live to the <video>, and the same filter is replayed on a canvas at capture time with ctx.filter. You can caption, set a 3/5/10 second view time, download, send to friends or groups, or post to My Story. If permission is denied it falls back to a sample photo or an upload.
  • Real video. Stories → Discover and Spotlight use the 15 shorts and the long-form episodes from Suhas Bhairav's channel (The Bhairav Show). Thumbnails come first, and only the active item mounts a youtube-nocookie.com iframe. Every player links back to YouTube.

What is simulated

The other people are simulated. The provider schedules client timers that walk each conversation through opened → typing → reply, and the reply text comes from a small rule-based picker behind POST /api/chat/reply. Server writes go to in-memory module state. The browser keeps the source of truth in localStorage, so the demo survives restarts. Media never leaves the browser: POST /api/snaps records only the fan-out metadata and TTL. There is no encryption. The rest of this guide is about what it takes to replace each of those simulations with a production system that holds up for millions of people. That means end-to-end encryption, guaranteed deletion of ephemeral media, a receipt state machine you can trust, and a realtime tier that keeps millions of sockets open.

How I built Glint with Claude: design, code and scaling plan

Glint 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.

StageClaude toolWhat it did
DesignClaude design canvasThe app screens were designed with Claude on a design canvas, one board per product.
CodingClaude CodeA Claude Code sub-agent wrote Glint: 41 files, 6 pages, 10 API routes, in about 31 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 10 screenshots that Claude reviewed and fixed.
Scalability & infrastructure analysisClaude Opus 5.5Capacity 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 Glint design uses an original name and original copy, so the clone borrows the product category and layout conventions, not Snapchat'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-app scaffold (Next.js 16, React 19, Tailwind CSS v4). Next 16 has breaking changes, so Claude was told to read the docs bundled in node_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 Glint agent worked for about 31 minutes. In that run Claude:

  1. Read the design file and the Next.js 16 docs it needed.
  2. Wrote the app: 41 source files, about 3,748 lines, with 6 page routes and 10 API route handlers (/api/bootstrap, /api/chat/reply, /api/chats, /api/chats/[id]/messages, /api/friends, /api/map, /api/search, /api/snaps, /api/spotlight, /api/stories).
  3. Ran npm run build and npm run lint until both passed cleanly.
  4. Started the production server and drove headless Chrome to capture 10 screenshots. For true phone-width shots it wrote a small Chrome DevTools Protocol script, because Chrome's --window-size flag won't lay out narrower than about 500px.
  5. 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

/ · /camera · /chat/[id] · /map · /spotlight · /stories

Screens walkthrough

Every screen below is a real route or query-string state, so each one can be bookmarked and screenshotted. All shots are 390×844 except the last.

1. Chat inbox

Glint chat inbox with New Snap, New Chat, Opened, Received and Delivered statuses and flame streaks

This is the design, reproduced. Row weight follows status: unread rows (New Snap, New Chat, purple video Snap) are bold, the rest are regular. The status square uses the design's radius rules: 2px corners for Snaps, 3px for chats, filled when new and outlined once handled. Friends with a story get a gradient ring around their avatar, and tapping the avatar opens their story instead of the chat. The Unread chip shows a live count, and the Chat tab carries the same number as a badge. Tapping a row with a pending Snap opens the viewer directly (/?snap=priya). Any other row opens the thread.

2. Chat thread with typing indicator

Chat thread with Yesterday and Today separators, saved messages and a typing indicator

Messages are grouped by sender with coloured name labels and a left rule: red for you, blue for the friend, and a distinct colour per member in groups. Day separators read Yesterday · 10:00 AM and Today · 10:20 AM. Saved messages have a grey background and a bookmark. Here I just sent "hey! still on for saturday?". It shows Opened, Priya is typing (header and inline dots), and a reply lands about two seconds later. The clock button switches the chat between delete after viewing and 24 hours after viewing.

3. Snap viewer

Full-screen Snap viewer with a countdown ring, caption bar and Reply in chat button

The countdown ring drains over the Snap's duration (6 seconds here). Hold to pause, tap to skip. The Snap is marked opened as soon as it renders, because that is the moment the sender should see Opened. The viewer snapshots its items on mount, so marking them opened does not pull them out from under the playback.

4. Camera with lenses

Live camera preview with flip, flash and upload controls and a lens carousel above the shutter

This is Chrome's fake media device (the green test pattern) with the Dream lens on. The right rail has flip, flash and upload from camera roll. The lens carousel sits above the shutter, and the shutter takes on the selected lens's swatch. Capturing draws the frame into a 540×960 canvas with cover-crop maths. The front camera is mirrored, the lens filter and overlay are applied, and the result is a JPEG data URL.

5. Send To

Send To sheet with My Story, friends sorted by streak and a blue send bar

Recipients are sorted by streak, then groups, then friends you have never chatted with (a chat is created for them on send). Opening the camera from a thread (/camera?to=priya) preselects that person. The blue bar lists the selection. Sending calls POST /api/snaps, which returns one delivery receipt per chat, and each row flips to Delivered.

6. Stories and Discover

Stories screen with friends' story rings, Bhairav Show episode cards and a Discover grid of shorts

The friends row puts unseen stories first. Subscriptions holds long-form episodes of The Bhairav Show. Discover is a 9:16 grid of the channel's shorts: the hqdefault thumbnail with object-fit: cover crops the letterboxing so only the vertical frame shows. Tapping one opens a full-screen player (/stories?discover=<id>) with prev/next and Watch on YouTube. The story viewer (/stories?story=hana) has segmented progress bars and a reply box that sends a chat.

7. Spotlight

Spotlight vertical short-video feed with like, send, share and YouTube buttons

A CSS scroll-snap feed (snap-y snap-mandatory). An IntersectionObserver at 60% visibility decides which card is active. Only that card mounts the iframe, and it is oversized so YouTube's own chrome is cropped. A transparent layer above the iframe keeps vertical swipes working and turns double-tap into a like. Pages load with a cursor from /api/spotlight.

8. Friend map

Stylised friend map with avatars, a selected friend card and a ghost mode button

An SVG city (river, parks, block grid, arterial roads) with avatars placed from /api/map. Drag to pan, wheel or buttons to zoom, and recenter on yourself. Selecting a friend (/map?friend=priya) shows their place and last-seen time with Chat and Snap actions. The ghost button opens a Ghost Mode switch that replaces your avatar with a ghost and stops the pulsing location ring.

9. Profile and streaks

Profile sheet with avatar, Glint Score, streak list and settings toggles

The profile sheet (top-left avatar, ?sheet=profile) has an editable display name, username, a score, counters, a streak list and settings: Ghost Mode, notifications, and the default delete policy for new chats.

10. Desktop

Glint shown as a centred phone column on a desktop browser

On viewports at least 640px wide and 860px tall, the app renders inside a 390×844 device frame on a neutral backdrop. On a phone it fills the screen with 100dvh.

Frontend architecture

Glint uses the Next.js 16 App Router with React 19 and Tailwind CSS v4, in plain JavaScript. cacheComponents and partialPrefetching are enabled, which matters in two places.

  1. Every screen that reads useSearchParams sits inside <Suspense>. Under Cache Components, search params are request-time data, so the static shell is a spinner and the client fills it in.
  2. The thread route awaits params inside a Suspense boundary. app/chat/[id]/page.js renders <Suspense><ThreadRoute params={params}/></Suspense>, and ThreadRoute awaits the promise. That makes /chat/[id] a partial prerender: a static shell with a streamed hole.

Routes

RouteScreenURL state
/Inbox?filter=unread|groups|stories, ?sheet=search|add|more|new|profile, ?snap=<chatId>
/chat/[id]Thread?snap=<messageId>
/cameraCamera?lens=<id>, ?to=<chatId>, ?sample=1, ?auto=1
/storiesStories / Discover?story=<friendId|me>, ?discover=<videoId>
/spotlightFeed?i=<index>
/mapMap?friend=<id>

Sheets and viewers are query-string states, not separate routes, so they layer over the current screen and the back button closes them. All state changes use router.replace(..., { scroll: false }) so the inbox keeps its scroll position.

State: one provider, pure updates

components/GlintProvider.js is the client store. It is a useReducer with a single generic action, fn: (state) => newState, plus small pure helpers (patchChat, pushMsg, mapMsgs, bump). Actions are memoised once and close over a stateRef, so timers never read stale state. The provider:

  • loads from localStorage (glint:v1) and falls back to GET /api/bootstrap. The seed stores "minutes ago" and the server converts that to absolute timestamps, so the inbox always reads 2m, 9m, 24m like the design.
  • persists with a 250 ms debounce. If the quota is exceeded (captured JPEGs are big) it trims My Story and retries.
  • purges 24-hour chats every minute and applies the delete after viewing rule when a thread unmounts. That cleanup is deferred by 80 ms and skipped if the same chat remounts, so React StrictMode's double-mount in development does not wipe a conversation.
  • simulates the other side. simulateReply(chatId, text, kind) chains timers: mark my messages seen, set typing, call POST /api/chat/reply, append the reply, and set the status to chat, received (if the thread is open) or snap. It also updates the streak when a Snap comes back on a day I snapped.

The thread tells the provider which chat is on screen through setActiveChat. That ref decides whether an incoming reply counts as read immediately.

Components

components/
  GlintProvider.js   store, persistence, simulation, toasts
  ui.js              Avatar, StatusSquare, Streak, BottomNav, Sheet, Toggle, Splash, ytThumb/ytEmbed
  icons.js           ~40 inline stroke icons incl. FlameIcon
  inbox/             Inbox, AddFriendsSheet, NewChatSheet, MoreMenu, SearchOverlay, ProfileSheet
  Thread.js          thread + LongPress save + delete-mode menu
  SnapViewer.js      countdown viewer shared by inbox and thread
  camera/            Camera (getUserMedia, lenses, capture, caption) + SendToSheet
  stories/           Stories (friends, subscriptions, discover, player) + StoryViewer
  Spotlight.js       scroll-snap feed with IntersectionObserver
  MapScreen.js       SVG map, pan/zoom, ghost mode
lib/
  seed.js status.js lenses.js replies.js server-store.js format.js

lib/status.js is a direct port of the design's renderVals() table (label, colour, fill, radius, weight per status). Inbox rows, thread receipts and the viewer all render from that one table, so a status always looks the same everywhere.

Data fetching

Seed data, search, friends, chats, messages, replies, snaps, stories, Spotlight pages and map positions all go through route handlers. Under Cache Components a GET handler is prerendered unless it touches request data, so the handlers whose responses change call await connection() or read request.nextUrl. Mutations are POST with JSON bodies. The message endpoint is idempotent on a client-generated clientId, the same contract a production send path needs.

Media handling in the browser

There are no image libraries. Avatars are colour swatches from the design with initials. Story and Snap photos are picsum.photos seeds. YouTube thumbnails come from i.ytimg.com. Using plain <img> avoids next/image remote-pattern configuration in a demo. A production build would serve its own media through next/image or a CDN image resizer.

Data model

The production model splits into two stores. PostgreSQL holds identity, the social graph, conversation metadata, ciphertext envelopes and receipts. Object storage holds encrypted media blobs. The server never stores plaintext message bodies or media keys. A row in message is an opaque ciphertext plus routing metadata, and one row exists per recipient device. That is how Signal-style fan-out works: the sender encrypts once per device session.

Core schema

CREATE TABLE app_user (
  id            bigint PRIMARY KEY,               -- snowflake: time-ordered, shard-friendly
  username      citext UNIQUE NOT NULL,
  display_name  text NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  ghost_mode    boolean NOT NULL DEFAULT false,
  deleted_at    timestamptz
);

CREATE TABLE device (
  id              bigint PRIMARY KEY,
  user_id         bigint NOT NULL REFERENCES app_user(id),
  identity_key    bytea NOT NULL,                 -- Curve25519 public identity key
  signed_prekey   bytea NOT NULL,
  spk_signature   bytea NOT NULL,
  pq_prekey       bytea,                          -- Kyber/ML-KEM last-resort key (PQXDH)
  push_token      text,
  push_kind       text CHECK (push_kind IN ('apns','fcm','webpush')),
  last_seen_at    timestamptz,
  revoked_at      timestamptz
);
CREATE INDEX ON device (user_id) WHERE revoked_at IS NULL;

CREATE TABLE one_time_prekey (                    -- consumed exactly once
  device_id  bigint NOT NULL REFERENCES device(id),
  key_id     int    NOT NULL,
  public_key bytea  NOT NULL,
  PRIMARY KEY (device_id, key_id)
);

CREATE TABLE friendship (
  user_id    bigint NOT NULL,
  friend_id  bigint NOT NULL,
  state      text   NOT NULL CHECK (state IN ('requested','accepted','blocked')),
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, friend_id)
);

CREATE TABLE conversation (
  id            bigint PRIMARY KEY,
  kind          text NOT NULL CHECK (kind IN ('direct','group')),
  title_enc     bytea,                            -- group name is E2EE metadata too
  mls_group_id  bytea,                            -- for MLS groups
  delete_policy text NOT NULL DEFAULT 'after_view' CHECK (delete_policy IN ('after_view','24h')),
  last_seq      bigint NOT NULL DEFAULT 0,        -- per-conversation sequence counter
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE conversation_member (
  conversation_id bigint NOT NULL,
  user_id         bigint NOT NULL,
  joined_seq      bigint NOT NULL,
  read_seq        bigint NOT NULL DEFAULT 0,
  streak_count    int    NOT NULL DEFAULT 0,      -- denormalised for the inbox (direct only)
  streak_expires  timestamptz,
  PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX ON conversation_member (user_id);

-- One envelope per recipient device. Partitioned by day; dropped wholesale after TTL.
CREATE TABLE message (
  conversation_id bigint      NOT NULL,
  seq             bigint      NOT NULL,
  recipient_dev   bigint      NOT NULL,
  sender_dev      bigint      NOT NULL,
  kind            smallint    NOT NULL,           -- 1 text, 2 snap, 3 video snap, 4 receipt, 5 control
  ciphertext      bytea       NOT NULL,
  media_id        uuid,                            -- object key for snaps (encrypted blob)
  client_id       uuid        NOT NULL,            -- idempotency key from the sender
  created_at      timestamptz NOT NULL DEFAULT now(),
  expires_at      timestamptz NOT NULL,
  PRIMARY KEY (recipient_dev, conversation_id, seq, created_at)
) PARTITION BY RANGE (created_at);
CREATE UNIQUE INDEX ON message (sender_dev, client_id, created_at);

CREATE TABLE receipt (
  conversation_id bigint NOT NULL,
  seq             bigint NOT NULL,
  user_id         bigint NOT NULL,
  state           smallint NOT NULL,              -- 1 sent 2 delivered 3 opened 4 screenshot 5 replayed
  at              timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (conversation_id, seq, user_id, state)
);

CREATE TABLE media_object (
  id            uuid PRIMARY KEY,
  bucket        text NOT NULL,
  bytes         int  NOT NULL,
  remaining     smallint NOT NULL,                -- recipients that have not opened yet
  expires_at    timestamptz NOT NULL,             -- 30 days unopened, 24h for stories
  deleted_at    timestamptz
);
CREATE INDEX ON media_object (expires_at) WHERE deleted_at IS NULL;

Why it is shaped like this

  • Envelopes are short-lived queue entries, not history. A message row exists until every recipient device has acknowledged it. After that the client owns the plaintext and applies the chat's delete policy locally. The server deletes the row on acknowledgement and partitions by day as a backstop: DROP TABLE message_p2026_11_04 removes a whole day of expired ciphertext in milliseconds, with no vacuum debt. That is the main reason for range partitioning on created_at.
  • Per-conversation seq. UPDATE conversation SET last_seq = last_seq + 1 ... RETURNING last_seq hands out a gap-free order inside a conversation. Clients detect gaps and request a resync. Global ordering is never needed.
  • Receipts are their own table, keyed by state. A receipt is append-only (delivered never turns back into sent) and screenshot or replay events are rare extras. The inbox status is derived from the newest receipt and cached in Valkey per (user, conversation).
  • Streaks are denormalised onto conversation_member with an expiry timestamp. A job looks for streaks expiring in the next 4 hours to send the hourglass warning.

Sharding

Shard by conversation_id for conversation, conversation_member, receipt and the sender side of message, so one conversation's sequence counter and receipts live on one shard. The recipient read path ("give me everything pending for device D") is served by a per-device inbox queue in NATS JetStream or Valkey Streams and does not scatter across shards. Postgres stays the durable record, and the realtime tier reads from the queue. Users and devices shard by user_id. Snowflake IDs carry a shard number in their low bits, so the router never needs a lookup table. Start with one primary and Citus or application-level routing in mind. You need the second shard well before 5M MAU because of write volume, not data size: message rows are tiny and short-lived.

API design

The production API is REST over HTTPS for request/response work, plus one WebSocket per device for everything pushed. All endpoints are versioned (/v1), authenticated with a short-lived access token bound to a device, and return application/json (or application/x-protobuf for mobile clients that opt in).

MethodPathPurposeNotes
POST/v1/devicesRegister a device with identity key, signed prekey and 100 one-time prekeysReturns device_id
GET/v1/users/{id}/prekey-bundleFetch a bundle for each of a user's devices to start a sessionConsumes one OTPK per device atomically
POST/v1/devices/me/prekeysTop up one-time prekeysClient refills below 20
GET/v1/conversations?cursor=Inbox: conversations ordered by last activityCursor = (last_activity, id)
POST/v1/conversationsCreate direct or group chatIdempotent on client_id
POST/v1/conversations/{id}/messagesSend one envelope per recipient deviceBody is a batch, server assigns seq
GET/v1/conversations/{id}/messages?after_seq=Gap fill after reconnectReturns only envelopes for the caller's device
POST/v1/media/uploadsGet a presigned PUT URL for an encrypted blobSize and content-length enforced
POST/v1/receiptsBatch receipts {conv, seq, state}Coalesced, max 100 per call
GET/v1/stories/feed?cursor=Friends' stories and DiscoverDiscover cached at the CDN
POST/v1/locationUpdate shared locationThrottled, dropped in ghost mode
GET/v1/friends/suggestionsQuick AddRate limited

Cursor pagination

Never use offsets. The inbox cursor is an opaque base64 of (last_activity_at, conversation_id), and the query is WHERE (last_activity_at, id) < ($1, $2) ORDER BY last_activity_at DESC, id DESC LIMIT 30 on a matching composite index. Message gap fill uses after_seq, which is already monotonic per conversation. In the clone, /api/chats/[id]/messages?before= and /api/spotlight?cursor= follow the same pattern.

Idempotency

Mobile networks retry. Every mutation carries a client-generated UUID: client_id on messages and conversations, and the Idempotency-Key header everywhere else. The send path relies on UNIQUE (sender_dev, client_id). On conflict it returns the original seq with 200 instead of creating a duplicate, so a retried Snap never shows up twice. The clone's POST /api/chats/[id]/messages already deduplicates on clientId. Store idempotency results for 24 hours in Valkey for endpoints that have no natural unique key.

Rate limits

A token bucket in Valkey per (user, action), enforced at the edge and the API:

ActionLimit (starting point)
Messages sent60 / minute, 2,000 / day
Snaps sent30 / minute, 600 / day, max 200 recipients per send
Friend requests20 / hour, 100 / day
Prekey bundle fetches300 / hour (stops key-exhaustion attacks)
Location updates1 / 15 s while foregrounded
Search30 / minute

Return 429 with Retry-After. New accounts get tighter buckets for their first week. That one rule removes most spam.

WebSocket protocol

One socket per device, multiplexed with small framed messages:

→ {"t":"hello","dev":123,"resume":{"conv:42":118,"conv:77":9}}
← {"t":"msg","conv":42,"seq":119,"env":"<base64 ciphertext>","kind":2}
→ {"t":"ack","conv":42,"seq":119}
← {"t":"rcpt","conv":42,"seq":117,"user":88,"state":"opened"}
→ {"t":"typing","conv":42,"on":true}
← {"t":"presence","user":88,"state":"active"}

hello carries the last acknowledged seq per conversation, so the gateway replays only what is missing. ack lets the server delete the envelope. Everything is binary (Protocol Buffers) on mobile. The JSON above is for readability.

Scaling to millions: capacity math and architecture

Capacity model and architecture drafted with Claude Opus 5.5 inside Claude Code. All figures are estimates with the arithmetic shown.

All numbers in this section are estimates for planning. They are not measurements of the clone. Change the per-user assumptions to match your own telemetry once you have it.

Assumptions (Scale tier, ~5M MAU)

InputValueReasoning
MAU5,000,000tier target
DAU2,500,00050% DAU/MAU: camera-chat apps are habit products
Chat messages sent / DAU / day40
Snaps sent / DAU / day20
Average recipients per Snap1.5most Snaps go 1:1, some to small groups
Average encrypted Snap size~0.33 MB85% photos at ~120 KB + 15% video at ~1.5 MB → 0.85×0.12 + 0.15×1.5 = 0.327 MB
Peak-to-average ratio3×evening peaks
Peak concurrent sockets20% of DAUforeground plus recently backgrounded

Derived load

  • Message writes: 2.5M × 40 = 100M/day → 100,000,000 / 86,400 ≈ 1,160/s average, ~3,500/s peak. Each send to a 1:1 chat with two devices on the other side writes 2 envelope rows plus 1 sender-sync row, so plan for ~10,000 row inserts/s at peak. That is comfortable for a well-tuned Postgres on NVMe with batched inserts, but it is the reason to plan a second shard before this tier.
  • Snap sends: 2.5M × 20 = 50M/day → ~580/s average, ~1,750/s peak.
  • Media ingress: 50M × 0.33 MB ≈ 16.5 TB/day uploaded.
  • Media egress: 16.5 TB × 1.5 recipients ≈ 24.75 TB/day, which is about 742 TB/month. As bandwidth: 24.75 TB / 86,400 s ≈ 286 MB/s ≈ 2.3 Gbit/s average, ~7 Gbit/s peak.
  • Media at rest: most Snaps are opened within hours and deleted on open. If the average lifetime is about 2 days with a long tail of unopened Snaps (30-day TTL), steady state is roughly 16.5 TB × 2 ≈ 33–40 TB stored. Retention, not ingest, drives storage cost, which is why aggressive deletion pays off twice.
  • Receipts: every message and Snap produces delivered and opened receipts → ~(100M + 75M) × 2 ≈ 350M receipts/day, about 4,000/s on average. Receipts are coalesced (one opened up to seq N replaces N individual events), which cuts this by 5–10×.
  • Concurrent sockets: 20% × 2.5M = 500,000 at peak.
  • Typing and presence events: ephemeral and never persisted. If 5% of sockets are typing at any moment with an update every 3 s, that is ~8,000 events/s fanned out to 1–30 recipients each.

Architecture (request path, top to bottom)

  1. DNS + CDN/WAF (Cloudflare or Bunny) for the web app, static assets and the Discover/Spotlight thumbnails. Encrypted Snap blobs bypass the CDN cache: each one is read once or twice, so caching buys nothing.
  2. Hetzner Load Balancers (or HAProxy pairs with keepalived on dedicated servers) split into two pools: api. for REST and ws. for WebSockets, with long idle timeouts.
  3. Stateless API tier (Node.js or Go) that handles auth, prekey bundles, sends, presigned uploads and receipts. Scale it horizontally on CPU.
  4. WebSocket gateway tier, stateful only in the sense that it holds sockets. It knows nothing about business logic. It subscribes to the per-device subjects of its connected devices on the backplane.
  5. NATS JetStream as the backplane and per-device durable inbox: subject dev.<id> with a work-queue stream, a max age of 30 days and acknowledge-to-delete. Kafka works too, but it is heavier to run yourself at this size.
  6. Valkey cluster for sessions, rate limits, presence (SET presence:<user> EX 60), typing, idempotency keys and inbox status caches.
  7. PostgreSQL: a primary plus a synchronous standby per shard, async read replicas for inbox and friend reads, PgBouncer in transaction mode in front (thousands of API connections become ~200 server connections), and daily partitions for message and receipt.
  8. Object storage (S3 API) for encrypted media, with lifecycle expiry rules. Uploads and downloads go straight from devices with presigned URLs, so the API never proxies bytes.
  9. Workers consuming JetStream: push notifications (APNs/FCM), streak expiry, media garbage collection, abuse-report processing, story fan-out.

Stateless app tier

API pods hold no session state. Tokens are verified locally with JWKS, and anything mutable lives in Valkey or Postgres. That lets you autoscale on CPU and do rolling deploys without draining. The gateway is the exception: draining a gateway means sending every socket a reconnect frame with jittered backoff (spread over 30–60 s) so the remaining nodes do not get a thundering herd.

Caching

Cache derived data, never ciphertext. That means the inbox list (inbox:<user>, a sorted set of conversation ids by last activity), per-conversation status for the viewer, friend lists, prekey bundle counts, and Discover pages at the CDN edge. Invalidate on write through the same NATS events the gateways consume. One event bus feeds both realtime and cache invalidation.

Multi-region

Start in one region (Falkenstein/Nuremberg) with backups in Helsinki. Messaging tolerates a little latency, but sockets do not like long round trips. Past ~5M MAU, put gateway-only edges near users (Ashburn, Hillsboro, Singapore) that terminate TLS and WebSockets locally and forward over a private backbone to the home region. Later, give each user a home region and route conversations to the region of their creator. Cross-region conversations then cost one extra hop for some participants, which is fine at messaging timescales. Media buckets live in the uploader's region. Recipients download cross-region, and that is still cheaper than replicating every blob.

Hetzner deployment

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 messaging because the workload is many sockets, steady CPU, NVMe Postgres and lots of egress, and Hetzner's traffic pricing and dedicated AX boxes are very cheap for exactly that. Prices below are excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering. Some shared Cloud plans show as "currently not available" in certain locations, so have a fallback plan size ready.

Honest limits first

  • Regions: Germany (Falkenstein, Nuremberg), Finland (Helsinki), USA (Ashburn VA, Hillsboro OR) and Singapore. Dedicated AX servers are in DE/FI only. There is nothing in South America, Africa or India, so put a CDN (Cloudflare or Bunny) in front for global latency on static assets, and add gateway edges where you need them.
  • No managed PostgreSQL. You run it yourself (Patroni + pgBackRest) or buy it from a provider that runs on Hetzner. This guide assumes self-managed.
  • Cloud traffic: EU cloud servers include 20 TB/month. US cloud plans include only 1–5 TB, so US gateway edges should not serve media.
  • Object Storage is S3-compatible and cheap (€6.49/month including 1 TB storage and 1 TB egress, then ~€6.35/TB-month storage and ~€1/TB egress, with free ingress, internal traffic and API calls). Benchmark request rates per bucket before relying on it for tens of TB/day, and spread keys across several buckets by hash prefix.
  • DDoS protection is automatic on every product. That covers volumetric attacks. You still need a WAF and application rate limits.

Stage 1, Launch (0–50K MAU): Docker Compose + Ansible

Two CPX32 (4 vCPU/8 GB) behind an LB11 run the Next.js app, the API and the WebSocket gateway as Compose services. Two more CPX32 run PostgreSQL 17 primary and streaming replica on local NVMe. One CPX22 runs Valkey and a single NATS server with JetStream. Everything sits in a Cloud Network (10.0.0.0/16). Only the load balancer is public. The Cloud Firewall allows 443 to the LB and 22 from a bastion or Tailscale. Ansible playbooks provision users, Docker, node_exporter and the Compose stacks. pgBackRest pushes WAL continuously and a full backup nightly to an Object Storage bucket in a different location.

Stage 2, Growth (~500K MAU): k3s on Cloud

Move to k3s with three control-plane CPX22 nodes (cheap etcd quorum) and worker pools. API pods run on CPX42 workers, gateways on dedicated-vCPU CCX23 (16 GB, so socket memory is predictable), and media and push workers on CPX42. Postgres moves to two CCX33 (8 dedicated vCPU/32 GB) managed by Patroni, with PgBouncer as a sidecar Deployment. NATS becomes a 3-node JetStream cluster. Use Hetzner Volumes only for things that can tolerate network block storage (NATS file store at this size, Prometheus). Keep Postgres on local NVMe. The hcloud cloud-controller-manager provisions LBs from Kubernetes Services, and the CSI driver handles Volumes.

Stage 3, Scale (~5M MAU): dedicated AX + k3s/Kubernetes

Cloud shared vCPU gets expensive and noisy once you are pushing gigabits of WebSocket traffic, so this is the stage to move to dedicated AX servers. An AX42 (Ryzen 7 PRO 8700GE, 8 cores, 64 GB ECC, from €79) handles about 150K idle WebSockets with room to spare. AX102 (16 cores, 128 GB, 2×1.92 TB NVMe DC, from €159) runs the Postgres shards. Link the servers with a vSwitch (VLAN) to your Cloud Network so cloud LBs and burst capacity can reach them. Order the 10 Gbit uplink add-on for gateway and media-heavy boxes, because the default 1 Gbit/s port saturates at the media peaks above. Kubernetes keeps running (k3s, or kubeadm with Cilium) across both the AX nodes and cloud nodes for bursty workers.

Stage 4, Hyperscale (10M+ MAU)

Run two DE/FI sites (Falkenstein + Helsinki) with Postgres shards spread so each primary's synchronous standby is in the other site, plus gateway edges in Ashburn and Singapore on cloud instances. Keep separate clusters per site rather than one stretched cluster, and federate them with NATS super-clusters (gateways between clusters) so a device connected in Helsinki gets messages published in Falkenstein.

When to move from cloud to dedicated

Move once a role needs more than ~3 CCX33-equivalents running continuously. A CCX33 is €138.99/month (NBG1) for 8 dedicated vCPU and 32 GB. An AX102 is €159 for 16 cores, 128 GB and 3.8 TB NVMe. Setup fees (€49–€304) pay for themselves within the first month. Keep cloud for anything that autoscales, anything short-lived, and US/Singapore edges.

Backups and drills

  • pgBackRest: continuous WAL archiving plus a weekly full and daily differential to Object Storage in another location, encrypted with a repo key. Target RPO under 1 minute, RTO under 1 hour per shard.
  • NATS: JetStream replicas=3 is your durability. It is not backed up, because envelopes are transient by design.
  • Do not back up media. Ephemeral media that survives in a backup is a privacy bug. Your lifecycle rules must cover every copy.
  • Run a restore drill monthly: restore the latest backup into a scratch server, replay WAL to a point in time and run consistency checks.

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

≈ €176.44 / month

Docker Compose + Ansible on Hetzner Cloud in one location, private Cloud Network, one LB11. App, API and WebSocket gateway share two nodes. Postgres primary + streaming replica. pgBackRest to Object Storage in another location. Prices: NBG1 list, excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering.

RoleHetznerSpecCountUnit € / moTotal € / mo
App + API + WebSocket gatewayCPX324 vCPU / 8 GB / 160 GB NVMe (shared)2€35.99€71.98
PostgreSQL primary + replicaCPX324 vCPU / 8 GB / 160 GB NVMe (shared)2€35.99€71.98
Valkey + NATS JetStreamCPX222 vCPU / 4 GB / 80 GB NVMe (shared)1€19.99€19.99
Object Storage (media + backups)Object Storagebase: 1 TB storage + 1 TB egress included1€6.49€6.49
Load BalancerLB11from ~€6/month, check console1€6.00€6.00

Growth· ~500K MAU

≈ €1,079.39 / month

k3s on Hetzner Cloud. Dedicated-vCPU nodes for gateways and Postgres (Patroni, PgBouncer), 3-node JetStream cluster. Object Storage estimate: ~4 TB stored (3 TB over base × €6.35) + ~74 TB/month egress (73 TB over base × ~€1). Estimates, excl. VAT, Nov 2026 list prices. Verify before ordering.

RoleHetznerSpecCountUnit € / moTotal € / mo
API / web (stateless)CPX428 vCPU / 16 GB / 320 GB NVMe3€69.99€209.97
WebSocket gatewaysCCX234 dedicated vCPU / 16 GB / 160 GB2€86.49€172.98
PostgreSQL primary + replica (Patroni)CCX338 dedicated vCPU / 32 GB / 240 GB2€138.99€277.98
Valkey (primary + replica)CPX324 vCPU / 8 GB / 160 GB2€35.99€71.98
NATS JetStream clusterCPX222 vCPU / 4 GB / 80 GB3€19.99€59.97
Workers (push, media GC, streaks, moderation)CPX428 vCPU / 16 GB / 320 GB2€69.99€139.98
Observability (Prometheus, Loki, Tempo, Grafana)CPX324 vCPU / 8 GB / 160 GB1€35.99€35.99
Object Storage (est. 4 TB stored, 74 TB egress)Object Storage6.49 base + 3 TB × 6.35 + 73 TB × ~11€98.54€98.54
Load Balancers (api + ws)LB11/LB21from ~€6/month each, check console2€6.00€12.00

Scale· ~5M MAU

≈ €3,389.14 / month

Dedicated AX servers in Falkenstein/Nuremberg on a vSwitch, Kubernetes across AX and cloud nodes, 2 Postgres shards (primary + sync standby each), 10 Gbit uplink add-on for gateway/media boxes (not included). Object Storage estimate: ~40 TB stored + ~742 TB/month egress. One-time setup fees are not included. Estimates, excl. VAT, Nov 2026 list prices. Verify before ordering.

RoleHetznerSpecCountUnit € / moTotal € / mo
API / web (stateless)AX42Ryzen 7 PRO 8700GE 8c / 64 GB DDR5 ECC / 2×512 GB NVMe4€79.00€316.00
WebSocket gateways (~150K sockets each)AX428c / 64 GB ECC / 2×512 GB NVMe6€79.00€474.00
PostgreSQL: 2 shards × (primary + standby)AX102Ryzen 9 7950X3D 16c / 128 GB ECC / 2×1.92 TB NVMe DC4€159.00€636.00
Valkey clusterAX428c / 64 GB ECC3€79.00€237.00
NATS JetStream clusterAX428c / 64 GB ECC / NVMe file store3€79.00€237.00
Workers (push, media GC, moderation, streaks)AX10216c / 128 GB ECC2€159.00€318.00
ObservabilityAX428c / 64 GB ECC2€79.00€158.00
Object Storage (est. 40 TB stored, 742 TB egress)Object Storage6.49 base + 39 TB × 6.35 + 741 TB × ~11€995.14€995.14
Cloud Load BalancersLB21/LB31from ~€6/month each, check console3€6.00€18.00

Hyperscale· 10M+ MAU

≈ €9,284.10 / month

Two sites (Falkenstein + Helsinki), 4 Postgres shards × 3 nodes with cross-site standbys, NATS super-cluster, US gateway edges on Ashburn cloud. Object Storage estimate: ~80 TB stored + ~1,500 TB/month egress. Setup fees and 10 Gbit uplinks are not included. Estimates, excl. VAT, Nov 2026 list prices. Verify before ordering.

RoleHetznerSpecCountUnit € / moTotal € / mo
API / web (stateless)AX10216c / 128 GB ECC / 2×1.92 TB NVMe8€159.00€1,272.00
WebSocket gateways (EU)AX428c / 64 GB ECC14€79.00€1,106.00
PostgreSQL: 4 shards × 3 nodesAX10216c / 128 GB ECC / 2×1.92 TB NVMe DC12€159.00€1,908.00
Valkey clusterAX10216c / 128 GB ECC6€159.00€954.00
NATS JetStream super-clusterAX10216c / 128 GB ECC5€159.00€795.00
WorkersAX10216c / 128 GB ECC4€159.00€636.00
US gateway edges (Ashburn)CPX31 (US)4 vCPU / 8 GB / 160 GB, 1–5 TB traffic4€62.99€251.96
ObservabilityAX10216c / 128 GB ECC2€159.00€318.00
Object Storage (est. 80 TB stored, 1,500 TB egress)Object Storage6.49 base + 79 TB × 6.35 + 1,499 TB × ~11€2,007.14€2,007.14
Cloud Load BalancersLB31from ~€6/month each, check console6€6.00€36.00

Realtime: sockets, presence, ordering and push

Real-time design and code drafted with Claude Opus 5.5 in Claude Code.

Realtime is the product. The inbox only feels alive if Delivered → Opened → typing… → New Chat happens within a few hundred milliseconds, which in the clone is a chain of client timers. In production it is a persistent connection per device.

Transport choice

  • WebSockets are the default: bidirectional, supported everywhere, cheap on the server with the right runtime. Mobile apps keep one socket while foregrounded.
  • SSE handles server-to-client only. It is fine for a web inbox, but typing and receipts go upstream constantly, so you would still need POSTs. Skip it.
  • WebTransport (HTTP/3) avoids head-of-line blocking and survives network changes better. It is worth adding later for clients on bad networks, but WebSocket remains the baseline because of proxy and corporate-network compatibility.
  • Mobile background: the OS kills sockets. Delivery to backgrounded devices goes through APNs/FCM. Web clients use Web Push with VAPID.

Gateway sizing (estimates)

A WebSocket gateway is a memory-and-file-descriptor problem before it is a CPU problem. With uWebSockets.js or a Go gateway (gorilla/gws or nbio), plan on 10–20 KB per idle connection including TLS state and subscription bookkeeping. 150,000 connections × 20 KB ≈ 3 GB, so an AX42 with 64 GB has huge headroom for memory. The real limits are TLS handshakes during reconnect storms and the outbound fan-out rate. I plan around ~150K connections per AX42-class box, which leaves enough CPU to absorb a reconnect storm.

  • Scale tier: 500,000 peak sockets / 150,000 ≈ 3.3 → 6 gateways (N+2 for deploys and zone loss).
  • Hyperscale: ~1.2M peak sockets / 150,000 = 8 → 14 gateways across two sites.

Kernel tuning: raise fs.nr_open and ulimit -n to 1M, set net.core.somaxconn=65535, widen net.ipv4.ip_local_port_range on the LB side, and enable TCP keepalive at 60 s. Send app-level pings every 25 s, because carrier NATs drop idle flows around 30–60 s.

Off-the-shelf or custom?

Centrifugo (Go, MIT) gives you channels, presence, history, recovery with offsets, JWT auth and a NATS/Redis broker out of the box. It is a strong choice up to millions of connections. Soketi is Pusher-compatible but lighter on recovery semantics. For a messaging product with per-device queues and E2EE envelopes, a custom gateway of a few hundred lines on uWebSockets.js is often simpler, because the gateway only has to do four things: authenticate, subscribe to dev.<id>, forward frames and relay acks.

Backplane and fan-out

Use fan-out on write for messages: the sender already produced one envelope per recipient device, so the API publishes each to dev.<recipientDevice> on NATS JetStream (a durable work-queue stream). Whichever gateway holds that device's socket has a consumer on that subject and pushes immediately. If the device is offline, the message waits in the stream (that is the offline queue) and a push notification goes out. Typing and presence are ephemeral: they go over core NATS subjects (conv.<id>.typing) with no persistence. Gateways subscribe per conversation only for sockets that have that conversation open on screen, which keeps typing fan-out small.

Location sharing is different. Each update fans out to N friends, but only to friends currently looking at the map. Publish to loc.<user>. Map screens subscribe to the subjects of friends in view and unsubscribe when the map closes. Throttle updates to every 15–30 s while foregrounded and never send location in background unless the user chose live location.

Ordering and delivery guarantees

  • Order per conversation, not globally. The server assigns seq in the conversation's shard. Clients render by seq and buffer out-of-order frames briefly.
  • At-least-once delivery plus idempotent clients. JetStream redelivers until the device acks. The client deduplicates on (conv, seq). The sender's client_id deduplicates retries on the send side. Together they give effectively-once in the UI.
  • Gap detection. If a client sees seq 120 after 118, it calls GET /messages?after_seq=118 instead of waiting.
  • Reconnect with resume. hello carries the last acked seq per active conversation (the last-event-id idea, per conversation). Reconnect backoff is exponential with full jitter: sleep = random(0, min(30s, 0.5s × 2^attempt)).

Backpressure

Each socket has a bounded send buffer, for example 256 KB. When getBufferedAmount() crosses it, the gateway stops pulling from that device's consumer (JetStream max-ack-pending does the rest). Typing and presence frames are dropped first, because they are ephemeral and only the latest matters. If the buffer stays full for 30 s, close the socket with code 1013 and let the client resume. Never let one slow phone on a train hold memory for everyone.

Push notifications

A push worker consumes a push.* stream. It is triggered when the API publishes an envelope to a device whose presence key in Valkey is absent or older than 60 s. For APNs use token-based auth (.p8 key) over HTTP/2 with apns-push-type: alert, mutable-content: 1 (so a Notification Service Extension can decrypt a preview on device), apns-collapse-id per conversation and apns-expiration matching the message TTL. For FCM use the HTTP v1 API with data-only high-priority messages (the app decrypts and builds the notification) and a collapse_key per conversation. For Web Push, VAPID with an encrypted payload and a TTL header. The payload never contains plaintext: it is the encrypted envelope or just "you have something" plus the conversation id. Respect the user's notification toggle (the clone's profile setting) on the server side as well.

Typing indicators and presence

Typing is client-throttled: send typing:on at most every 3 s while keys are pressed and typing:off on send or blur. Gateways expire a typing state after 5 s without refresh, so a crashed client never leaves a stuck "typing…". Presence is a Valkey key with a 60 s TTL refreshed by socket pings. "Active now" is computed on read, not broadcast to every friend on every change.

Deep dive: ephemeral media, E2E encryption and guaranteed deletion

Deep-dive drafted with Claude Opus 5.5 in Claude Code.

This is the system that makes a camera-chat product hard. Users are promised two things that pull against each other: messages arrive instantly on every device, and messages are gone afterwards. Both have to be true at the same time, without the server being able to read anything.

1. End-to-end encryption

1:1 chats: the Signal protocol. Each device publishes an identity key, a signed prekey and a stack of one-time prekeys (plus a post-quantum ML-KEM prekey for PQXDH). To start a session, the sender fetches a prekey bundle per recipient device, runs X3DH/PQXDH to derive a shared secret, and then uses the Double Ratchet. Every message advances a symmetric chain (forward secrecy), and every reply round trip mixes in a fresh Diffie-Hellman (post-compromise security). The server only stores public keys and relays ciphertext. Use libsignal (Rust, with Swift/Java/TypeScript bindings). Do not write your own ratchet.

Groups: MLS (RFC 9420). Pairwise Signal sessions in a group cost O(n) encryptions per message, and adding or removing members gets messy. MLS uses a ratchet tree, so adding or removing a member costs O(log n) and every member derives the same epoch secret. One ciphertext per message goes to the whole group. OpenMLS (Rust) is production-grade. The delivery service (your server) orders commits per group. That ordering requirement is exactly the per-conversation seq from the data model. Under ~10 members, Signal "sender keys" are a simpler stopgap, but MLS is where the industry is going.

Multi-device. Treat each device as its own cryptographic identity. A user's devices form a device list, and a sender encrypts to every device of every recipient, plus their own other devices so sent messages sync. New devices are linked by scanning a QR code on an existing device, which transfers the account identity key over an ephemeral channel. History does not transfer by default, which suits an ephemeral product. Key changes surface as a "safety number changed" notice. Add key transparency (an append-only, auditable log of identity keys, in the style of CONIKS or the key-transparency logs WhatsApp and Apple published) once you are big enough to be a target.

What the server still sees is metadata: who talks to whom and when, sizes and IP addresses. Minimise it. Use sealed sender (the sender's identity goes inside the ciphertext, and delivery is authorised with an anonymous delivery token), short log retention, and padded media sizes in fixed buckets.

2. Ephemeral media with per-object keys

The device compresses first (§5), then generates a random 256-bit media key per Snap and encrypts the blob with AES-256-GCM (or ChaCha20-Poly1305 on low-end Android). It uploads the ciphertext to object storage through a presigned PUT to a random UUID key. The media key, digest and object id travel inside the E2EE message. The server stores bytes it cannot decrypt and holds no key that could decrypt them.

Crypto-shredding makes deletion real: once every recipient device's copy of the media key is gone, the ciphertext is noise, wherever it was copied. Devices delete the key when the Snap is opened (and keep a separate local copy only for "replay once"). The server's job is to delete the ciphertext promptly as a second line of defence.

3. TTL and guaranteed deletion

Run three layers of deletion, and assume each one can fail on its own:

  1. On open: a recipient's opened receipt decrements media_object.remaining. At zero, a worker issues DELETE on the object within seconds.
  2. Lifecycle rules: every media bucket has an S3 lifecycle expiration (for example, prefix snaps/ expires after 31 days and stories/ after 2 days), so even if the database and workers are both broken, the storage itself enforces the maximum TTL. Lifecycle rules run on day granularity, which is why they are the backstop and not the main mechanism.
  3. Sweeper: an hourly job scans media_object WHERE expires_at < now() AND deleted_at IS NULL, deletes the objects and marks the rows. It also catches orphans: uploads whose message was never sent.

Make deletion observable. Export media_objects_overdue_total (rows past expires_at and not yet deleted) and alert if it is ever non-zero for more than an hour. Never back up media buckets, keep media out of CDN caches (Cache-Control: no-store on presigned GETs), and keep access logs for days, not months. Message envelopes in Postgres follow the same pattern: delete on ack, with daily partitions dropped after the max TTL.

4. Delivery receipts as a state machine

           upload ok          gateway push/ack       viewer shown        OS screenshot hook
 [sending] ────────▶ [sent] ───────────────▶ [delivered] ──────────▶ [opened] ──▶ [screenshotted]
     │                                                                   │
     └──── fail after retries ──▶ [failed]                               └──▶ [replayed] (once)

States only move forward. Receipts are themselves E2EE control messages (kind 4), so the server never learns that a Snap was opened, only that a small envelope went back. The sender's client folds the receipts it receives into the inbox status: Delivered (filled red or outline blue), Opened, Screenshot. That is exactly what lib/status.js renders in the clone. Coalesce receipts ("opened up to seq N") and batch them every ~500 ms to cut traffic by an order of magnitude. Screenshot detection is best-effort: iOS exposes userDidTakeScreenshotNotification after the fact, Android 14+ has a screenshot-detected callback, and the web has nothing. Present it honestly as a deterrent, not a guarantee.

5. On-device capture, compression and upload

  • Photos: capture at the preview resolution, apply lenses on the GPU (Metal/Vulkan shaders, or WebGL in the browser; the clone uses Canvas 2D ctx.filter), then encode HEIC/AVIF on capable devices or JPEG at quality ~0.8. That lands around 80–200 KB at 1080p.
  • Video: H.264 (or HEVC where supported) with hardware encoders at 720p/1080p, ~2–3 Mbit/s, 10 s max per segment, AAC audio. Longer captures are chunked into multiple Snaps.
  • Upload: start uploading while the user is still typing a caption or picking recipients. Encrypt and upload speculatively to a temporary key and commit on send. That one trick makes sending feel instant. Use resumable or multipart uploads for video, retry with exponential backoff, and keep a persistent outbox so a Snap sent in a tunnel goes out when the network comes back.
  • Strip metadata: remove EXIF and GPS before encryption unless the user explicitly attaches a location.

6. Streak computation

A streak counts consecutive days on which both people sent each other at least one Snap (chats do not count). Store per pair: streak_count, last_day_a, last_day_b and expires_at. On each Snap send from A to B, evaluated in UTC or in the pair's agreed timezone:

  1. Set last_day_a = today.
  2. If last_day_b == today and the streak has not been incremented today, increment it and set expires_at = end_of(tomorrow).
  3. A job looks for pairs with expires_at - now() < 4h and sends the "hourglass" warning push.
  4. Another job resets streaks past expires_at to 0.

Because Snap contents are E2EE, the server must not infer streaks from content. It uses envelope metadata (kind = Snap) that it already sees for routing. Be transparent with users that this metadata exists. The clone keeps the same rule in miniature: iSnappedDay plus a Snap coming back on the same day increments the streak once.

7. Location sharing privacy

Location is the most sensitive data in the app, so build it so that it is opt-in, coarse by default and short-lived:

  • Ghost Mode (the clone's toggle) means the client stops sending updates and the server deletes the last location. It is not just a flag that hides it at read time.
  • Audience: all friends, selected friends, or "all friends except". Evaluate the audience at fan-out time, not when the map renders.
  • Precision: snap to a ~100 m grid for most audiences. Precise location only for an explicit "share live location for 1 hour" session.
  • E2EE location: encrypt location updates per friend (the same Signal sessions) so the server routes only ciphertext. Heatmaps and public "Snap Map" style features then need a separate, explicitly public, aggregated pipeline.
  • Retention: keep only the last location with a TTL of 8–24 h (a Valkey key with EX). Never keep a history unless the user turns on a feature that needs one.
  • Never update location in the background without OS-level "always" permission and a visible indicator.

Security, privacy and compliance

Threat model and mitigations drafted with Claude in Claude Code.

Authentication and sessions

  • Sign-in: phone number or email with OTP for account recovery, passkeys (WebAuthn) as the primary factor, and OIDC (Sign in with Apple/Google) as an option. Rate-limit OTP sends per number and per IP, and use a CAPTCHA or Play Integrity / App Attest signal before sending SMS. SMS pumping fraud is a real cost line.
  • Sessions: a short-lived access token (JWT, 10–15 min, signed with rotating keys published as JWKS) bound to a device_id, plus an opaque refresh token stored hashed in Postgres and rotated on every use, with reuse detection that revokes the device. Web uses an HttpOnly; Secure; SameSite=Lax cookie instead of storing tokens in JS. Sessions here are per device anyway, so revocation is "delete the device row and close its socket".
  • Authorisation: every conversation operation checks membership (conversation_member), and media GETs need a presigned URL that the API issues only to members. Object keys are random UUIDs, never sequential.

OWASP Top 10, specific to this app

RiskWhere it bites a camera-chat appMitigation
Broken access controlFetching someone else's prekeys, messages or media by idMembership checks on every query, presigned URLs with 5-minute expiry, no listable buckets
Cryptographic failuresHome-grown crypto, nonce reuse in media encryptionlibsignal/OpenMLS, random 96-bit GCM nonces with a fresh key per object, TLS 1.3 only
InjectionSearch, usernamesParameterised queries, citext usernames with a strict charset
Insecure designServer-side "delete" that leaves backupsCrypto-shredding and lifecycle rules as described above
Security misconfigurationPublic bucket, debug endpointsInfra as code, CI policy checks, separate buckets per data class
Vulnerable componentsImage decoders (libwebp-class bugs)Decode only on device, keep deps patched, Renovate + SCA in CI
Auth failuresOTP brute force, SIM swapAttempt limits, passkeys, registration lock PIN
Integrity failuresMalicious app updates or lens packagesSigned builds, signed lens bundles
Logging failuresNo trail for account takeoverAudit log below
SSRFLink previewsGenerate previews on the sender's device, never on the server

Headers and CSP (web)

Content-Security-Policy: default-src 'self'; img-src 'self' blob: data: https://i.ytimg.com https://*.your-cdn.com; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com; connect-src 'self' wss://ws.example.com https://*.your-object-storage.com; script-src 'self' 'nonce-…'; frame-ancestors 'none', plus Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and Permissions-Policy: camera=(self), microphone=(self), geolocation=(self).

Abuse, spam and moderation under E2EE

The server cannot read content, so moderation has to rely on: user reports (the reporter's client decrypts and attaches the reported messages, a "message franking" scheme proves they really were sent), metadata signals (account age, send velocity, friend-request acceptance rate, many recipients who never reply), on-device classifiers that warn recipients ("this may be sensitive, view anyway?"), and legally required CSAM handling where your jurisdiction mandates it, designed with counsel. Public surfaces (Spotlight, public stories, Discover) are not E2EE, so they go through a server-side pipeline: malware scanning on upload, perceptual hashing against known-abuse hash lists, ML classifiers and a human review queue.

Secrets, encryption and network

  • Secrets live in SOPS-encrypted files (age keys) or Vault. They never go in images or env files in git. Kubernetes uses External Secrets.
  • TLS 1.3 at the edge, mTLS or WireGuard between nodes over the private network (vSwitch/Cloud Network), LUKS full-disk encryption on dedicated servers, and pgBackRest repo encryption.
  • DDoS: Hetzner's automatic network-level protection, a CDN/WAF in front of HTTP, connection-rate limits on the WebSocket LB, and per-IP connection caps on gateways.

GDPR and India's DPDP Act

Collect the minimum. Publish retention periods (they are short by design, so say so). Support data export (profile, friends, saved chats on request from the client, because the server does not have plaintext) and full account deletion within 30 days, including devices, prekeys, friend edges and any media. Sign DPAs with processors (Hetzner, push providers, SMS vendor). Get explicit consent for location sharing. Record a lawful basis for each processing purpose. Keep EU data in EU regions. Under DPDP, appoint a grievance officer and handle children's data with verifiable parental consent.

Audit logging

Write an append-only security log of sign-ins, device links and revocations, key changes, password or passkey changes, data exports, admin actions and moderation decisions. Ship it to a separate Loki tenant or object store with write-once retention for 1 year. Never put message content or media keys in any log.

Observability and operations

Telemetry stack

  • Metrics: Prometheus (or VictoriaMetrics once cardinality grows) scraping node_exporter, postgres_exporter, PgBouncer, NATS (/varz, JetStream), Valkey and app metrics through OpenTelemetry SDKs. Grafana dashboards per tier.
  • Logs: structured JSON to Loki through Grafana Alloy. Log ids and states, never content.
  • Traces: OpenTelemetry → Tempo. Trace the send path end to end: API POST /messages → Postgres insert → NATS publish → gateway push → client ack. The trace id travels in NATS headers.

The metrics that matter for this product

MetricWhy
send_to_deliver_ms p50/p95/p99Time from API accept to recipient gateway push. SLO: p95 < 300 ms within a region
deliver_to_ack_msClient health and network quality
ws_connections per gateway, ws_connect_rateCapacity and reconnect storms
jetstream_pending per consumer groupOffline queue growth, stuck consumers
push_sent_total, push_failures_total{reason}APNs/FCM token churn, quota problems
media_upload_fail_ratio, upload p95Client and network issues
media_objects_overdue_totalDeletion guarantee. SLO: zero objects more than 1 h past TTL
Postgres replication lag, PgBouncer wait queueDatabase health

SLOs and alerting

  • Message delivery availability: 99.95% of sends accepted and pushed or queued within 5 s, per 30 days.
  • Inbox load: p95 < 400 ms.
  • Ephemeral deletion: 100% of media deleted by TTL + 1 h. This is treated as a privacy SLO, and missing it pages someone.

Alert on burn rate (multi-window, multi-burn-rate as in the Google SRE workbook), not on raw thresholds. Page only on user-visible impact or a privacy SLO breach. Send everything else to a ticket queue. Run a weekly on-call rotation with runbooks linked from every alert: gateway drain, Patroni failover, JetStream stream repair, APNs certificate or key expiry.

Load testing

Use k6 with the WebSocket module for socket soak tests. Ramp to 1.5× expected peak connections, hold for an hour, and measure memory per connection and GC pauses. Simulate reconnect storms by killing a gateway under load. Run k6 HTTP scenarios for the send path at 3× peak write rate against a production-sized staging database. For media, upload synthetic 120 KB and 1.5 MB blobs at peak rate to verify object storage throughput per bucket.

Chaos and recovery drills

Monthly: kill a Postgres primary (Patroni should promote within ~30 s), kill a NATS node, drain a gateway during peak, and restore a pgBackRest backup to a point in time. Quarterly: a regional failover game day. Record time to detect and time to recover.

CI/CD

GitHub Actions or Forgejo: lint, unit tests, Playwright end-to-end tests against an ephemeral environment (the clone's URL-addressable states make these easy to write), container build with SBOM and signing (cosign), then deploy through Argo CD with progressive rollout: canary 5% of API pods, then 50%, then all, with automatic rollback on SLO burn. Database migrations are expand/contract and backwards compatible for one release. Gateway deploys drain gradually, a few percent of sockets per minute.

Cost breakdown

Cost model calculated by Claude Code from the Hetzner line items above, excl. VAT.

These are monthly running costs for the four infrastructure tiers in the tables on this page. They use Hetzner list prices, excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering. Cost per 1K users is computed at the top of each tier's MAU range, so it is the best case for that tier. All traffic and storage figures are estimates derived in the scaling section.

TierUsersInfra / monthPer 1K MAUof which Object Storage
Launch0–50K MAU€176.44€3.53€6.49
Growth~500K MAU€1,079.39€2.16€98.54
Scale~5M MAU€3,389.14€0.68€995.14
Hyperscale10M+ MAU€9,284.10€0.93€2,007.14

Not included: one-time setup fees for AX servers (€49–€304 each), 10 Gbit uplink add-ons, a CDN/WAF plan (Cloudflare or Bunny), SMS for OTP (often the largest variable cost in a phone-number-first app: budget per verification and fight SMS pumping), Apple/Google developer accounts, and people. APNs and FCM are free.

Main cost drivers

  1. Media egress and retention. At the Scale tier, Object Storage is about a third of the bill, and nearly all of that is egress (742 TB × ~€1/TB). Hetzner's egress pricing is what makes this affordable at all. The same traffic on a hyperscaler at typical list prices of several cents per GB would cost tens of thousands of euros a month.
  2. Database write volume. Envelope rows and receipts, not storage. Sharding comes from write IOPS and WAL volume.
  3. Sockets. Gateways are cheap per box but scale linearly with concurrent users.
  4. SMS verification, if you lead with phone numbers.

How to cut them

  • Compress on device harder. Moving photos from JPEG to AVIF/HEIC at the same visual quality typically saves 30–50% of bytes, and egress falls with it.
  • Delete on open, aggressively. Every hour a blob sticks around after its last open is wasted storage. Lifecycle rules are the backstop, not the plan.
  • Coalesce receipts and typing. Batch receipts every ~500 ms and send typing at most every 3 s. That removes most small writes and frames.
  • Use dedicated AX servers early for steady loads (gateways, Postgres, NATS). One AX102 at €159 replaces several cloud instances.
  • Keep media out of the CDN. Ephemeral blobs have near-zero cache hit rates, and paying CDN egress on top of origin egress is waste. Use the CDN for the app shell, thumbnails and public Discover/Spotlight only.
  • Passkeys first, SMS second. Every sign-in that skips SMS saves money and removes an attack surface.

Roadmap: from this clone to a production launch

Phase 1: foundations (weeks 1–4)

  • Replace the localStorage store with a real backend: Postgres schema from this guide, migrations, snowflake ids.
  • Accounts: phone/email OTP + passkeys, device registry, refresh-token rotation.
  • Device keys and prekey bundles with libsignal. Encrypt text messages end to end.
  • WebSocket gateway + NATS JetStream per-device inbox. Replace the clone's reply timers with real delivery and E2EE receipts.
  • Turn /api/chats/[id]/messages into the batched per-device envelope endpoint with client_id idempotency.

Phase 2: ephemeral media (weeks 5–8)

  • Presigned upload/download to Hetzner Object Storage, per-object AES-GCM keys, speculative upload during caption entry.
  • Deletion triple: delete on last open, lifecycle rules, hourly sweeper, plus the media_objects_overdue_total alert.
  • Native camera apps (Swift/Kotlin) with GPU lenses. Keep the web camera from the clone for desktop.
  • Stories with 24 h TTL. Replay-once and screenshot receipts.

Phase 3: groups, push and safety (weeks 9–12)

  • MLS groups with OpenMLS. Group admin and membership commits ordered per conversation.
  • APNs/FCM/Web Push workers with encrypted payloads and collapse keys.
  • Rate limits, new-account throttles, reporting with message franking, moderation tools for public surfaces.
  • Streak service and hourglass notifications.

Phase 4: launch readiness (weeks 13–16)

  • Location sharing with Ghost Mode, coarse default precision and E2EE updates.
  • Observability stack, SLOs, runbooks, on-call.
  • k6 soak at 1.5× target sockets, chaos drills, restore drill.
  • Privacy policy, DPA, GDPR/DPDP export and deletion flows, external security review and E2EE protocol audit.
  • Staged rollout: closed beta → one country → general availability, with the Hetzner tier sized one step ahead of demand.

Implementation snippets

Live lens capture to canvas

How the clone applies a lens live with CSS and replays the same filter on a canvas at capture time, mirroring the selfie camera and cover-cropping to 9:16.

// components/camera/Camera.js (excerpt): the same lens is applied live (CSS) and at capture (canvas)
const W = 540, H = 960;

function drawCover(ctx, src, sw, sh, mirror) {
  const scale = Math.max(W / sw, H / sh);           // cover-crop the camera frame into 9:16
  const dw = sw * scale, dh = sh * scale;
  ctx.save();
  if (mirror) { ctx.translate(W, 0); ctx.scale(-1, 1); } // selfie preview is mirrored, so is the capture
  ctx.drawImage(src, (W - dw) / 2, (H - dh) / 2, dw, dh);
  ctx.restore();
}

function captureFrame(video, lens, facing) {
  const c = document.createElement("canvas");
  c.width = W; c.height = H;
  const ctx = c.getContext("2d");
  ctx.filter = lens.css;                               // e.g. "saturate(1.8) contrast(1.12)"
  drawCover(ctx, video, video.videoWidth, video.videoHeight, facing === "user");
  ctx.filter = "none";
  drawOverlay(ctx, lens.overlay, W, H);                // vignette / tint gradients
  return c.toDataURL("image/jpeg", 0.82);
}

const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
  audio: false,
});

Receipt state machine and coalescing

Receipts only move forward. The sender's inbox status is derived from the newest state, and batches collapse to 'up to seq N' per state.

// Receipt state machine: states only move forward; the inbox status is derived from it.
type State = "sending" | "sent" | "delivered" | "opened" | "screenshotted" | "failed";
const RANK: Record<State, number> = { failed: -1, sending: 0, sent: 1, delivered: 2, opened: 3, screenshotted: 4 };

export function applyReceipt(current: State, incoming: State): State {
  if (current === "failed") return incoming === "sent" ? "sent" : current; // a retry succeeded
  return RANK[incoming] > RANK[current] ? incoming : current;              // ignore late/duplicate receipts
}

// Inbox row for the sender, matching the design's status table
export function inboxStatus(kind: "text" | "snap", s: State) {
  if (s === "opened" || s === "screenshotted") return kind === "snap" ? "opened" : "openedChat";
  if (s === "delivered" || s === "sent") return kind === "snap" ? "deliveredSnap" : "delivered";
  return s; // sending / failed rendered inline in the thread
}

// Coalesced receipts: one "opened up to seq N" message replaces N events
export function coalesce(receipts: { conv: number; seq: number; state: State }[]) {
  const best = new Map<string, { conv: number; seq: number; state: State }>();
  for (const r of receipts) {
    const k = r.conv + ":" + r.state;
    const prev = best.get(k);
    if (!prev || r.seq > prev.seq) best.set(k, r);
  }
  return [...best.values()];
}

Send path with per-conversation sequence and daily partitions

Allocate a gap-free seq and write one ciphertext envelope per recipient device in one statement, idempotent on client_id. Expired days are dropped as partitions.

-- Send path: allocate a per-conversation sequence number and write one envelope per device
-- in one transaction. Idempotent on (sender_dev, client_id).
WITH s AS (
  UPDATE conversation SET last_seq = last_seq + 1
  WHERE id = $1
  RETURNING last_seq
)
INSERT INTO message (conversation_id, seq, recipient_dev, sender_dev, kind, ciphertext, media_id, client_id, expires_at)
SELECT $1, s.last_seq, d.dev, $2, $3, d.ct, $4, $5, now() + interval '30 days'
FROM s, unnest($6::bigint[], $7::bytea[]) AS d(dev, ct)
ON CONFLICT DO NOTHING
RETURNING seq;

-- Daily partitions; expired days are dropped, not deleted row by row.
CREATE TABLE IF NOT EXISTS message_p20261112 PARTITION OF message
  FOR VALUES FROM ('2026-11-12') TO ('2026-11-13');

-- Run nightly (pg_cron): drop partitions older than the maximum TTL.
DO $$
DECLARE p text;
BEGIN
  FOR p IN SELECT inhrelid::regclass::text FROM pg_inherits
           WHERE inhparent = 'message'::regclass
             AND inhrelid::regclass::text < 'message_p' || to_char(now() - interval '31 days', 'YYYYMMDD')
  LOOP
    EXECUTE format('DROP TABLE %s', p);
  END LOOP;
END $$;

WebSocket gateway on uWebSockets.js + NATS JetStream

A minimal gateway: authenticate on upgrade, consume the device's durable inbox, push with backpressure, ack to delete, and relay ephemeral typing events.

// Minimal WebSocket gateway on uWebSockets.js + NATS JetStream per-device inbox.
import uWS from "uWebSockets.js";
import { connect, AckPolicy } from "nats";
import { verifyAccessToken } from "./auth.js";

const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const jsm = await nc.jetstreamManager();
const MAX_BUFFER = 256 * 1024;

uWS.App().ws("/v1/socket", {
  idleTimeout: 60,               // seconds; client pings every 25s
  maxBackpressure: MAX_BUFFER,
  upgrade: async (res, req, ctx) => {
    const token = req.getQuery("token");
    const key = req.getHeader("sec-websocket-key");
    const proto = req.getHeader("sec-websocket-protocol");
    const ext = req.getHeader("sec-websocket-extensions");
    let aborted = false;
    res.onAborted(() => { aborted = true; });
    const claims = await verifyAccessToken(token).catch(() => null);
    if (aborted) return;
    if (!claims) return res.writeStatus("401").end();
    res.cork(() => res.upgrade({ dev: claims.dev, user: claims.sub }, key, proto, ext, ctx));
  },
  open: async (ws) => {
    const data = ws.getUserData();
    // durable consumer per device on the work-queue stream "INBOX" (subject dev.<id>)
    const name = "dev-" + data.dev;
    await jsm.consumers.add("INBOX", { durable_name: name, filter_subject: "dev." + data.dev, ack_policy: AckPolicy.Explicit, max_ack_pending: 500 }).catch(() => {});
    const consumer = await js.consumers.get("INBOX", name);
    data.sub = await consumer.consume({
      callback: (m) => {
        if (ws.getBufferedAmount() > MAX_BUFFER) return m.nak(2000);  // backpressure: retry later
        data.pending = data.pending || new Map();
        data.pending.set(m.headers?.get("seq-key"), m); // publisher sets "conv:seq"
        ws.send(m.data, true);
      },
    });
  },
  message: (ws, buf) => {
    const frame = JSON.parse(Buffer.from(buf).toString());
    const data = ws.getUserData();
    if (frame.t === "ack") {
      const m = data.pending?.get(frame.conv + ":" + frame.seq);
      if (m) { m.ack(); data.pending.delete(frame.conv + ":" + frame.seq); }
    } else if (frame.t === "typing") {
      nc.publish("conv." + frame.conv + ".typing", JSON.stringify({ user: data.user, on: !!frame.on }));
    }
  },
  close: (ws) => ws.getUserData().sub?.stop(),
}).listen(9001, (ok) => console.log(ok ? "gateway on :9001" : "listen failed"));

JetStream inbox stream and object-storage lifecycle backstop

One work-queue stream holds every device's offline inbox for up to 30 days. S3 lifecycle rules cap media lifetime even if the app's own deletion fails.

# NATS JetStream: one work-queue stream holds every device's offline inbox.
nats stream add INBOX \
  --subjects 'dev.*' \
  --storage file --replicas 3 \
  --retention work \
  --max-age 30d \
  --max-msg-size 64KB \
  --discard old --dupe-window 2m

# Ephemeral typing/presence subjects use core NATS (no stream, no persistence).
nats sub 'conv.42.typing'

# Object storage lifecycle backstop for ephemeral media (Hetzner Object Storage is S3-compatible).
cat > lifecycle.json <<'JSON'
{ "Rules": [
  { "ID": "snaps-max-ttl",   "Status": "Enabled", "Filter": { "Prefix": "snaps/" },   "Expiration": { "Days": 31 } },
  { "ID": "stories-max-ttl", "Status": "Enabled", "Filter": { "Prefix": "stories/" }, "Expiration": { "Days": 2 } },
  { "ID": "abort-uploads",   "Status": "Enabled", "Filter": { "Prefix": "" }, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 } }
] }
JSON
aws s3api put-bucket-lifecycle-configuration \
  --endpoint-url https://fsn1.your-objectstorage.com \
  --bucket glint-media-01 --lifecycle-configuration file://lifecycle.json

Per-Snap encryption with WebCrypto and presigned upload

A fresh AES-256-GCM key per Snap with size padding. The key and digest go inside the E2EE message, the ciphertext goes to object storage.

// Per-Snap media encryption in the browser with WebCrypto (AES-256-GCM).
// The key, IV and digest travel inside the E2EE message; the server only stores ciphertext.
export async function encryptSnap(blob: Blob) {
  const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plain = new Uint8Array(await blob.arrayBuffer());
  // pad to a 16 KB bucket so object sizes leak less about content
  const padded = new Uint8Array(Math.ceil((plain.length + 4) / 16384) * 16384);
  new DataView(padded.buffer).setUint32(0, plain.length);
  padded.set(plain, 4);
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, padded));
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", ct));
  const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
  return { ciphertext: ct, attachment: { key: rawKey, iv, digest, size: ct.length } };
}

export async function uploadSnap(ct: Uint8Array) {
  const { url, mediaId } = await fetch("/v1/media/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ bytes: ct.length }),
  }).then((r) => r.json());
  const res = await fetch(url, { method: "PUT", body: ct, headers: { "Content-Type": "application/octet-stream" } });
  if (!res.ok) throw new Error("upload failed " + res.status);
  return mediaId; // goes into the encrypted message, never alongside the key in plaintext
}

Streak computation in PostgreSQL

Mutual daily Snap streaks stored per ordered pair, bumped once per day when both sides have snapped, with hourglass warning and reset jobs.

-- Streaks: both people must Snap each other on the same day. Called on every Snap send.
CREATE TABLE streak (
  user_lo   bigint NOT NULL,
  user_hi   bigint NOT NULL,
  count     int    NOT NULL DEFAULT 0,
  day_lo    date,
  day_hi    date,
  bumped_on date,
  expires_at timestamptz,
  PRIMARY KEY (user_lo, user_hi)
);

-- $1 = sender, $2 = recipient, $3 = today (in the pair's timezone)
INSERT INTO streak (user_lo, user_hi, day_lo, day_hi)
VALUES (LEAST($1,$2), GREATEST($1,$2),
        CASE WHEN $1 < $2 THEN $3 END, CASE WHEN $1 > $2 THEN $3 END)
ON CONFLICT (user_lo, user_hi) DO UPDATE SET
  day_lo = CASE WHEN $1 < $2 THEN $3 ELSE streak.day_lo END,
  day_hi = CASE WHEN $1 > $2 THEN $3 ELSE streak.day_hi END
RETURNING *;

UPDATE streak SET
  count = CASE WHEN expires_at IS NULL OR expires_at > now() THEN count + 1 ELSE 1 END,
  bumped_on = $3,
  expires_at = ($3 + 2)::timestamptz        -- end of tomorrow
WHERE user_lo = LEAST($1,$2) AND user_hi = GREATEST($1,$2)
  AND day_lo = $3 AND day_hi = $3 AND bumped_on IS DISTINCT FROM $3;

-- Hourglass warnings (every 15 min) and resets (hourly)
SELECT user_lo, user_hi, count FROM streak
WHERE expires_at BETWEEN now() AND now() + interval '4 hours' AND count >= 3;
UPDATE streak SET count = 0 WHERE expires_at < now() AND count > 0;

nginx in front of the WebSocket gateways

least_conn balancing for long-lived sockets, per-IP connection caps and timeouts longer than the client ping interval.

# HAProxy-style WebSocket balancing with nginx on the dedicated-server tier.
upstream ws_gateways {
    least_conn;                       # long-lived sockets: balance by open connections
    server 10.0.1.11:9001 max_fails=2 fail_timeout=10s;
    server 10.0.1.12:9001 max_fails=2 fail_timeout=10s;
    server 10.0.1.13:9001 max_fails=2 fail_timeout=10s;
    keepalive 64;
}

limit_conn_zone $binary_remote_addr zone=perip:10m;

server {
    listen 443 ssl;
    http2 off;                        # WebSocket upgrade over HTTP/1.1
    server_name ws.glint.example;
    ssl_protocols TLSv1.3;

    location /v1/socket {
        limit_conn perip 20;          # one person, a few devices, not a botnet
        proxy_pass http://ws_gateways;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 120s;      # > client ping interval (25s)
        proxy_send_timeout 120s;
        proxy_buffering off;
    }
}

Related videos from The Bhairav Show

Frequently asked questions

How did you build the Snapchat clone with Claude Code?

I designed Glint 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 31 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 Snapchat clone?

Claude Code can build a complete, working frontend with API routes quickly, as Glint 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 Snapchat clone?

Start with the inbox and its status state machine (sent, delivered, opened), then a camera that captures to a canvas, a Send To picker, and a full-screen viewer that deletes on view. This clone does all of that in Next.js 16 with React 19 and Tailwind v4. For production, add Signal-protocol encryption, per-device WebSocket delivery through NATS JetStream, and encrypted media in object storage with TTL deletion.

How do disappearing messages actually get deleted?

Use three layers. The recipient's open receipt triggers deletion of the blob within seconds. Object-storage lifecycle rules enforce a maximum TTL even if your workers fail. An hourly sweeper catches orphans and anything overdue. Because each Snap is encrypted with its own key that lives only inside the E2EE message, deleting the key on the devices (crypto-shredding) makes any leftover copy unreadable.

Is end-to-end encryption required for a Snapchat-like app?

Legally it is usually not required, but users of ephemeral messaging assume privacy, and E2EE is the only way to make 'we cannot see your Snaps' true. Use libsignal (X3DH/PQXDH plus the Double Ratchet) for 1:1 chats and MLS (RFC 9420, via OpenMLS) for groups. The server then stores only public keys, ciphertext and routing metadata.

How many WebSocket connections can one server handle?

With uWebSockets.js or a well-tuned Go gateway, idle connections cost roughly 10–20 KB each, so memory allows hundreds of thousands per box. In practice I plan about 150,000 connections per 8-core, 64 GB dedicated server to leave headroom for TLS handshakes during reconnect storms. At 500,000 peak sockets that means about six gateways including spares.

How does Snapchat calculate streaks?

A streak counts consecutive days on which both people sent each other at least one Snap. Chats do not count. Store the last Snap day for each side of the pair, increment once per day when both days equal today, set an expiry at the end of tomorrow, warn a few hours before expiry, and reset to zero after it passes.

What is the difference between Delivered, Received and Opened?

Delivered means your message reached the recipient's device but has not been viewed. Opened means they viewed it. Received means someone sent you something and you have already viewed it. New Snap and New Chat mean it is unread. The clone renders these from one status table, ported from the design, with red for Snaps, blue for chats and purple for video Snaps.

How do you send push notifications for encrypted messages?

Send data-only or mutable-content pushes that carry the encrypted envelope or just a conversation id, never plaintext. On iOS a Notification Service Extension decrypts the preview on the device. On Android the app builds the notification from a high-priority FCM data message. Use collapse keys per conversation and set expiry to the message TTL.

Can I host a messaging app like this on Hetzner?

Yes. Hetzner suits it well: cheap dedicated AX servers for gateways and PostgreSQL, S3-compatible Object Storage with low egress pricing, and built-in DDoS protection. You run Postgres yourself because there is no managed offering. Regions are limited to Germany, Finland, the USA and Singapore, so add a CDN and gateway edges for global latency.

How much does it cost to run a Snapchat clone for 5 million users?

By the estimates in this guide, about €3,389.14 per month in Hetzner infrastructure for ~5M MAU, excluding setup fees, CDN, SMS verification and staff. Roughly a third of that is object-storage egress for media. Prices are excl. VAT, as listed on hetzner.com in Nov 2026. Verify before ordering.

How does the camera filter work in a web Snapchat clone?

The clone gets the camera with getUserMedia, applies each lens as a CSS filter plus an optional gradient overlay on the live video element, and at capture draws the frame to a canvas with ctx.filter set to the same filter string. That makes the saved image match the preview. Native apps do the same with GPU shaders.

How do you keep messages in order across devices?

Order within each conversation, not globally. The conversation's shard assigns a monotonically increasing sequence number per message. Clients render by sequence, detect gaps and fetch missing ranges, and resume after reconnect by sending the last acknowledged sequence per conversation. Delivery is at least once, with deduplication on the client.

How do you protect location privacy on a friend map?

Make sharing opt-in with a Ghost Mode that actually stops uploads and deletes the last location. Default to coarse, roughly 100 m precision. Encrypt updates per friend. Keep only the latest location with a short TTL. Fan out only to friends who currently have the map open.

Is this Snapchat clone open source and can I run it locally?

The clone lives at clones/snapchat-clone in the project repository and runs with npm install and npm run dev. Its server writes are in-memory and the browser keeps state in localStorage, so it is a working demo, not a production backend. The guide covers what to replace for production.

More SaaS clones