A full-stack real-time chat app with group chats, voice notes, and peer-to-peer voice and video calls.
Messages, photos, videos and voice notes are end-to-end encrypted in the browser, so the server stores data it can't read.
Demo video · Try it · Features · Architecture · How it works · Tech stack · Run it locally · Testing · API reference · Deployment
Demo video (2.5 minutes): every feature, then the stack and architecture. Click to play.
![]() |
![]() |
![]() |
| Photos, video and audio | Dark mode | On a phone |
Open the live app and press Try the demo account, or log in by hand:
| Username | Password | Encryption passphrase |
|---|---|---|
alice |
password123 |
alice demo passphrase (filled in for you) |
Good to know:
- The demo account is shared, so its passphrase is public.
- Alice's chats show every feature: photos, a video, an audio clip, replies, reactions, edited, deleted and forwarded messages, call logs and two groups.
- Demo chats are not end-to-end encrypted. The seeded people have never logged in and have no keys, so those chats are encrypted on the server only.
- To see end-to-end encryption, sign up two accounts of your own.
- One-on-one and group chats, with typing indicators, read receipts and online status
- Replies, edits, delete for everyone, reactions and forwarding
- Images, video and voice notes, uploaded straight from the browser to Cloudinary; large photos are scaled down first
- Chat list shows each chat's newest message and how many are unread
- Disappearing messages: set a chat to delete new messages after 1 hour, 1 day or 7 days
- Search every chat's messages from the sidebar; picking a result jumps to that message
- Offline sending: messages written offline wait in an outbox and go out once the server is reachable, never twice, in the same order on every device
- Fast opening: chats open from an IndexedDB cache, then refresh from the server (75% faster on 3G, see Cache benchmark)
- Opens offline: a service worker keeps the app's files, so saved chats open with no network
- Several servers: can run on many servers at once, joined through Redis (see Load test)
- Voice and video calls between browsers over WebRTC, with mute and camera toggles
- Missed and finished calls are logged in the chat
- Push notifications for new messages while the app is closed (on iPhone, after adding it to the home screen)
- Create groups, rename them, change the icon, add and remove members
- Several admins per group; the last admin can't be removed
- Drafts your next message from the last few messages of the chat
- Pick a tone (Auto, Professional, Casual or Funny), then edit the draft before sending
- In an end-to-end encrypted chat it asks first, since the recent messages leave the browser as plain text
- End-to-end encryption for one-on-one chats and groups (ECDH P-256 and AES-GCM in the Web Crypto API), with a lock in the chat header when it's on
- Encrypted attachments: in those chats photos, videos and voice notes are encrypted before upload, each with its own key, so the media host can't open them
- Safety numbers: compare a number with the other person to check nobody swapped their key, and get a warning in the chat if a key changes later
- Passphrase backup of your key, so a new browser can read your history; the server never sees the passphrase
- AES-256 encryption at rest for everything else: chats where someone has no key yet, call logs and group notices
- Profanity is masked (
****) before a message is sent, in the browser for encrypted chats - Private profiles don't show up in user lists, and can't be messaged by new people or added to groups
- Light, dark or system theme, with five accent colors
- Works on phones, with larger tap targets on touch screens
- Respects the "reduce motion" system setting
| Topic | In one line |
|---|---|
| Requests | Vercel serves the app and forwards /api/* to Render |
| Login | JWT in an HttpOnly cookie, plus a 5-minute token for the socket |
| Real-time updates | One socket room per user and per group |
| Delivery | Outbox, client-made IDs and per-chat sequence numbers |
| Several servers | Redis joins the Socket.IO servers together |
| End-to-end encryption | Keys made in the browser; the server only sees ciphertext |
| Encryption at rest | AES-256-CBC for text the server can read |
| Search | Keyed hashes of words, so encrypted text can still be found |
| Input checks | zod schemas on every route and socket event |
| Calls | WebRTC between browsers, with STUN and TURN |
| Notifications | Web Push with VAPID keys and a service worker |
| Magic reply | Last 10 messages go to Groq for a draft |
| Uploads | Browser uploads straight to Cloudinary |
The full write-up, with the delivery, scaling and encryption details, is in docs/how-it-works.md.
| Area | Tools |
|---|---|
| Frontend | React 19, TypeScript, Vite 8, Mantine 9, React Router 8, Zustand 5, Motion, Phosphor Icons, Socket.io client, idb |
| Backend | Node.js 24, TypeScript (run by Node directly), Express 5, Socket.io 4, Mongoose 9, zod 4, JWT, bcrypt, helmet, express-rate-limit, leo-profanity |
| Services | MongoDB, Cloudinary, Groq, Google STUN, Cloudflare TURN |
| Hosting | Vercel (frontend and /api proxy), Render (API and sockets), GitHub Actions (keep-alive ping) |
| Tooling | Docker Compose, nginx, Redis, k6, pnpm, Vitest, supertest, mongodb-memory-server, ESLint 10 (flat config, typescript-eslint), GitHub Actions CI, D2 |
| Suite | Size | Runs on |
|---|---|---|
| Backend | 184 tests, about 86% line coverage | Vitest, supertest, mongodb-memory-server, real socket.io-client sockets |
| Frontend | 65 tests | Vitest, Testing Library on jsdom, fake-indexeddb, real Web Crypto |
| End to end | One full two-browser run | Playwright, the real backend on an in-memory MongoDB, and the Vite app |
GitHub Actions runs all three suites, ESLint and the production build on every push. What each suite checks is in docs/testing.md.
Opening a chat, with and without the IndexedDB cache (median of 10 runs, 50 messages):
| Network | No cache | With cache | Faster by |
|---|---|---|---|
| No throttling | 198 ms | 166 ms | 16% |
| Fast 4G | 240 ms | 174 ms | 28% |
| Slow 4G | 289 ms | 111 ms | 62% |
| 3G | 407 ms | 104 ms | 75% |
Message delivery latency under load (k6, 60-second runs, all on one laptop):
| Setup | Load | Delivered | Missing | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| 1 server | 100 pairs, 50 msg/s | 2,900 | 0 | 61 ms | 178 ms | 320 ms |
| 3 servers + Redis | 100 pairs, 50 msg/s | 2,898 | 2 | 56 ms | 213 ms | 376 ms |
| 1 server | 250 pairs, 125 msg/s | 7,249 | 1 | 17 ms | 398 ms | 747 ms |
| 3 servers + Redis | 250 pairs, 125 msg/s | 7,231 | 2 | 8 ms | 766 ms | 1,572 ms |
How these were measured, and how to read them, is in docs/testing.md.
You need Docker with Compose. One command starts MongoDB, the backend and the frontend:
git clone https://github.com/Sarcastic-Soul/ChatApp.git
cd ChatApp
docker compose up --build -d
docker compose exec backend node seed.ts # demo users, password123Then open http://localhost:8080 and log in as alice.
Running without Docker, the three-server setup, every script and the environment variables are in docs/development.md.
| Page | What's in it |
|---|---|
| API reference | All 39 routes, built from the zod schemas the server checks input with |
| How it works | Delivery, scaling, encryption and search in detail |
| Development | Running locally, scripts, environment variables and the folder tree |
| Testing | What the tests cover, and how the benchmarks were measured |
| Security | What is and isn't protected, and how to report a problem |
MIT. See LICENSE.



