Three months ago, I made a stupid bet with my coworker: "I can build a better chat app than our company's internal messenger in a month."
Spoiler alert: It took three months, not one. But holy shit, it turned out way better than I expected.
What started as a bruised-ego project became an obsession. I ended up building a production-ready chat application with end-to-end encryption, voice/video calls, stories, and a UI so clean that my designer girlfriend actually said "wait, you built this?"
That compliment alone made the sleepless nights worth it.
Let me show you exactly how I architected this thing, from websockets to that buttery-smooth message animation that everyone keeps asking about.
Why Build Another Chat App?
Fair question. We already have WhatsApp, Telegram, Signal, and approximately 47 other messaging apps.
But here's the thing—I wanted to understand how these apps actually work under the hood. How do you handle real-time messaging at scale? How do you implement end-to-end encryption? How do you make voice calls work smoothly?
Plus, building a chat app touches almost every interesting problem in mobile development:
- Real-time communication
- Offline-first architecture
- Media handling
- Push notifications
- Complex animations
- State synchronization
It's basically a masterclass in React Native development disguised as a messaging app.
The Architecture Deep Dive
I spent the first two weeks just sketching architecture diagrams on my whiteboard. My roommate thought I was planning a heist.
High-Level System Design
┌──────────────────┐
│ Mobile Client │
│ (React Native) │
└────────┬─────────┘
│
┌────┴─────┐
│ │
┌───┴───┐ ┌──┴────┐
│ REST │ │Socket │
│ API │ │Server │
└───┬───┘ └──┬────┘
│ │
┌───┴─────────┴───┐
│ Redis Pub/Sub │
└────────┬─────────┘
│
┌────┴────┐
│ │
┌───┴────┐ ┌─┴─────────┐
│Postgres│ │ MongoDB │
│(Users) │ │ (Messages) │
└────────┘ └───────────┘
│
┌────┴────┐
│ │
┌───┴────┐ ┌─┴──────┐
│Firebase│ │ S3 │
│Storage │ │(Media) │
└────────┘ └────────┘
Why These Choices?
WebSockets for Real-Time: Socket.io made the most sense. Yes, I looked at GraphQL subscriptions. No, I didn't want that complexity.
Redis for Pub/Sub: When user A sends a message, Redis broadcasts it to all connected servers. This lets us scale horizontally without messages getting lost.
MongoDB for Messages: Documents scale better for chat messages than relational tables. Plus, sharding is straightforward when you inevitably hit millions of messages.
PostgreSQL for Users: Relational data (friends, contacts, groups) still needs a relational database. Don't fight it.
S3 for Media: Because storing images in your database is a crime against humanity.
The Folder Structure That Saved My Sanity
After three failed attempts, here's the structure I finally settled on:
chatflow/
├── src/
│ ├── app/ # Expo Router
│ │ ├── (auth)/
│ │ │ ├── login.tsx
│ │ │ ├── register.tsx
│ │ │ ├── verify-phone.tsx
│ │ │ └── setup-profile.tsx
│ │ ├── (tabs)/
│ │ │ ├── index.tsx # Chats list
│ │ │ ├── calls.tsx
│ │ │ ├── stories.tsx
│ │ │ └── settings.tsx
│ │ ├── chat/
│ │ │ ├── [id].tsx # Individual chat
│ │ │ └── group-info.tsx
│ │ ├── call/
│ │ │ ├── voice/[id].tsx
│ │ │ └── video/[id].tsx
│ │ ├── story/
│ │ │ ├── create.tsx
│ │ │ └── view/[id].tsx
│ │ └── _layout.tsx
│ │
│ ├── components/
│ │ ├── ui/ # Base components
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Avatar.tsx
│ │ │ ├── Badge.tsx
│ │ │ ├── BottomSheet.tsx
│ │ │ └── Skeleton.tsx
│ │ ├── chat/
│ │ │ ├── MessageBubble.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── VoiceRecorder.tsx
│ │ │ ├── MediaPicker.tsx
│ │ │ ├── TypingIndicator.tsx
│ │ │ └── DateSeparator.tsx
│ │ ├── call/
│ │ │ ├── CallControls.tsx
│ │ │ ├── VideoRenderer.tsx
│ │ │ └── CallNotification.tsx
│ │ ├── story/
│ │ │ ├── StoryViewer.tsx
│ │ │ ├── StoryCreator.tsx
│ │ │ ├── StoryRing.tsx
│ │ │ └── StoryProgress.tsx
│ │ └── common/
│ │ ├── ChatListItem.tsx
│ │ ├── ContactItem.tsx
│ │ ├── SwipeableRow.tsx
│ │ └── EmptyState.tsx
│ │
│ ├── features/
│ │ ├── auth/
│ │ │ ├── hooks/
│ │ │ │ ├── useAuth.ts
│ │ │ │ └── usePhoneVerification.ts
│ │ │ ├── services/
│ │ │ │ └── authService.ts
│ │ │ └── types.ts
│ │ ├── messaging/
│ │ │ ├── hooks/
│ │ │ │ ├── useMessages.ts
│ │ │ │ ├── useSocket.ts
│ │ │ │ └── useMessageQueue.ts
│ │ │ ├── services/
│ │ │ │ ├── messageService.ts
│ │ │ │ ├── encryptionService.ts
│ │ │ │ └── socketService.ts
│ │ │ └── types.ts
│ │ ├── calls/
│ │ │ ├── hooks/
│ │ │ │ ├── useWebRTC.ts
│ │ │ │ └── useCallState.ts
│ │ │ ├── services/
│ │ │ │ └── webRTCService.ts
│ │ │ └── types.ts
│ │ ├── media/
│ │ │ ├── hooks/
│ │ │ │ ├── useImagePicker.ts
│ │ │ │ ├── useCamera.ts
│ │ │ │ └── useMediaUpload.ts
│ │ │ ├── services/
│ │ │ │ ├── uploadService.ts
│ │ │ │ └── compressionService.ts
│ │ │ └── types.ts
│ │ └── stories/
│ │ ├── hooks/
│ │ │ └── useStories.ts
│ │ ├── services/
│ │ │ └── storyService.ts
│ │ └── types.ts
│ │
│ ├── store/ # Zustand stores
│ │ ├── authStore.ts
│ │ ├── chatStore.ts
│ │ ├── messageStore.ts
│ │ ├── callStore.ts
│ │ ├── storyStore.ts
│ │ └── uiStore.ts
│ │
│ ├── services/
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ ├── auth.ts
│ │ │ ├── messages.ts
│ │ │ ├── users.ts
│ │ │ └── media.ts
│ │ ├── socket/
│ │ │ └── socketManager.ts
│ │ ├── database/ # Local SQLite
│ │ │ ├── schema.ts
│ │ │ └── queries.ts
│ │ ├── encryption/
│ │ │ ├── e2ee.ts
│ │ │ └── keyManager.ts
│ │ ├── notifications/
│ │ │ └── pushNotifications.ts
│ │ └── storage/
│ │ ├── secureStorage.ts
│ │ └── fileSystem.ts
│ │
│ ├── utils/
│ │ ├── formatters.ts
│ │ ├── validators.ts
│ │ ├── helpers.ts
│ │ ├── timeUtils.ts
│ │ └── linkParser.ts
│ │
│ ├── hooks/
│ │ ├── useKeyboard.ts
│ │ ├── useNetworkStatus.ts
│ │ ├── useAppState.ts
│ │ ├── useDebounce.ts
│ │ └── useHaptics.ts
│ │
│ ├── constants/
│ │ ├── colors.ts
│ │ ├── typography.ts
│ │ ├── layout.ts
│ │ └── config.ts
│ │
│ └── types/
│ ├── models.ts
│ ├── api.ts
│ └── navigation.ts
│
├── assets/
│ ├── fonts/
│ ├── images/
│ ├── sounds/ # Message tones
│ └── animations/
│
├── app.json
├── package.json
└── tsconfig.json
This structure is the result of many mistakes. I originally tried feature-first, then component-first, and finally landed on this hybrid that actually makes sense.
The Tech Stack (Battle-Tested)
Every piece of tech here earned its place by solving a real problem, not because it was trending on Twitter.
Frontend Core
React Native 0.74 + Expo SDK 51
- Expo Router for file-based navigation
- EAS for builds and updates
- Expo modules for native features
State Management: Zustand + React Query
- Zustand for client state (simple, fast, no boilerplate)
- React Query for server state (caching, sync, optimistic updates)
- Why not Redux? Because life's too short
Real-Time: Socket.io Client
- Automatic reconnection
- Binary data support
- Room-based messaging
UI & Styling: NativeWind + Reanimated
- Tailwind utilities for styling
- Reanimated for buttery 60fps animations
- React Native Gesture Handler for swipes
Video Calls: react-native-webrtc
- Peer-to-peer video/audio
- Screen sharing support
- Works with STUN/TURN servers
Local Database: WatermelonDB
- SQLite wrapper optimized for React Native
- Lazy loading for performance
- Perfect for offline-first architecture
Media: Expo Image Picker + Video
- Image/video selection
- Camera integration
- Compression before upload
Encryption: crypto-js + react-native-rsa-native
- AES-256 for message encryption
- RSA for key exchange
- Secure key storage
Backend Stack
- API: Node.js + Express + TypeScript
- Real-Time: Socket.io with Redis adapter
- Databases: PostgreSQL (users) + MongoDB (messages)
- Cache: Redis (sessions, presence, pub/sub)
- Storage: AWS S3 + CloudFront CDN
- Search: Elasticsearch (message search)
- Queue: Bull (for background jobs)
- WebRTC: Mediasoup (SFU for group calls)
The UI That Made My Designer Jealous
I'm a developer, not a designer. But I spent an ungodly amount of time making this app look and feel premium.
Design Philosophy
1. WhatsApp-Inspired, Not Copied
- Familiar patterns users already know
- But with modern touches and better animations
- Dark mode that actually looks good
2. Animation-First Thinking
- Every interaction has feedback
- Micro-animations everywhere
- Gesture-driven (swipe to reply, long-press for reactions)
3. Minimalist but Warm
- Clean interfaces with personality
- Subtle gradients and shadows
- Rounded corners (12px is the magic number)
4. Typography Hierarchy
- Inter for UI (it's 2025, we're past Roboto)
- SF Pro on iOS, Google Sans on Android for system consistency
- Three sizes max per screen
5. Thoughtful Empty States
- No boring "No messages yet" screens
- Illustrations that make you smile
- Clear CTAs to get started
Color System
// Light Mode
const colors = {
primary: {
main: '#00A884', // WhatsApp green, timeless
dark: '#008069',
light: '#D9FDD3',
},
secondary: {
main: '#667080', // Cool gray
dark: '#3D4350',
light: '#F0F2F5',
},
accent: '#FF6B35', // Notification orange
background: {
primary: '#FFFFFF',
secondary: '#F7F8FA',
tertiary: '#ECE5DD', // Chat background
},
message: {
sent: '#D9FDD3',
received: '#FFFFFF',
},
text: {
primary: '#111B21',
secondary: '#667781',
tertiary: '#8696A0',
},
status: {
online: '#00D863',
typing: '#00A884',
offline: '#8696A0',
},
};
// Dark Mode
const darkColors = {
primary: {
main: '#00A884',
dark: '#008069',
light: '#005C4B',
},
background: {
primary: '#0B141A',
secondary: '#1F2C33',
tertiary: '#0B141A',
},
message: {
sent: '#005C4B',
received: '#1F2C33',
},
text: {
primary: '#E9EDEF',
secondary: '#8696A0',
tertiary: '#667781',
},
};
Core Features That Actually Matter
1. Real-Time Messaging (The Heart)
This is where things got interesting. Here's how messages flow through the system:
// Simplified message flow
const sendMessage = async (text: string, chatId: string) => {
const tempId = uuid();
// 1. Add to local queue immediately
addMessageToQueue({
id: tempId,
text,
chatId,
status: 'sending',
timestamp: Date.now(),
});
// 2. Encrypt the message
const encrypted = await encryptMessage(text, chatId);
// 3. Send via socket
socket.emit('message:send', {
tempId,
chatId,
encrypted,
});
// 4. Update status based on server response
socket.on('message:sent', (data) => {
updateMessageStatus(tempId, 'sent', data.serverId);
});
// 5. Handle failures gracefully
socket.on('message:failed', () => {
updateMessageStatus(tempId, 'failed');
});
};
Message States:
- Sending (clock icon)
- Sent (single check)
- Delivered (double check)
- Read (blue double check)
- Failed (red exclamation)
2. End-to-End Encryption
I implemented a Signal-like protocol (simplified version):
- Key Exchange: RSA public/private keys generated on signup
- Message Encryption: AES-256 with unique session keys
- Key Rotation: New session keys every 100 messages
- Forward Secrecy: Old messages can't be decrypted if keys are compromised
The server never sees plaintext. Ever.
3. Offline-First Architecture
This was the hardest part. Users expect chat apps to work everywhere, even in subway tunnels.
How It Works:
- All messages stored in local SQLite via WatermelonDB
- Sent messages queued locally if offline
- Auto-retry with exponential backoff
- Conflict resolution when back online
- Smart sync (only fetch missed messages, not entire history)
4. Voice & Video Calls
Built on WebRTC with a custom signaling server:
const initiateCall = async (recipientId: string, isVideo: boolean) => {
// 1. Create peer connection
const peer = new RTCPeerConnection(stunConfig);
// 2. Add local stream
const stream = await getMediaStream(isVideo);
stream.getTracks().forEach(track => peer.addTrack(track, stream));
// 3. Create and send offer
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
socket.emit('call:offer', {
recipientId,
offer,
isVideo,
});
// 4. Wait for answer
socket.on('call:answer', async (answer) => {
await peer.setRemoteDescription(answer);
});
// 5. Exchange ICE candidates
peer.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('call:ice-candidate', {
recipientId,
candidate: event.candidate,
});
}
};
};
Features:
- 1-on-1 calls (peer-to-peer)
- Group calls up to 8 people (via Mediasoup SFU)
- Push-to-talk for voice calls
- Picture-in-picture mode
- Call history and missed call notifications
5. Stories (Like Instagram but Faster)
24-hour ephemeral content:
- Upload photos/videos with filters
- View stories in sequence
- See who viewed your story
- Reply to stories with messages
- Auto-advance to next person's story
Performance hack: Stories are pre-cached when you open the tab, so they load instantly.
6. Message Reactions
Long-press any message to react with emojis. It's simple but users love it:
- 7 quick reactions (❤️ 😂 😮 😢 😡 🙏 👍)
- Animated pop-up animation
- Multiple people can react
- See who reacted to what
7. Media Sharing
Compress before upload, always:
const uploadImage = async (uri: string) => {
// 1. Compress image (maintains quality but reduces size by ~70%)
const compressed = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1920 } }],
{ compress: 0.8, format: SaveFormat.JPEG }
);
// 2. Generate thumbnail
const thumbnail = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 400 } }],
{ compress: 0.6, format: SaveFormat.JPEG }
);
// 3. Upload in parallel
const [imageUrl, thumbUrl] = await Promise.all([
uploadToS3(compressed.uri),
uploadToS3(thumbnail.uri),
]);
return { imageUrl, thumbUrl };
};
8. Smart Search
Search through millions of messages in milliseconds:
- Full-text search powered by Elasticsearch
- Search by contact, message content, or media
- Highlights matching terms
- Jump to message in conversation
The Animations That Make It Special
I use Reanimated 3 for everything. Here's the buttery-smooth message send animation everyone asks about:
const MessageBubble = ({ message, sent }) => {
const scale = useSharedValue(0.5);
const opacity = useSharedValue(0);
useEffect(() => {
scale.value = withSpring(1, {
damping: 15,
stiffness: 150,
});
opacity.value = withTiming(1, { duration: 200 });
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.bubble, animatedStyle]}>
{/* Message content */}
</Animated.View>
);
};
Other animations:
- Typing indicator (three bouncing dots)
- Swipe to reply (with haptic feedback)
- Pull to load older messages
- Story progress bars
- Emoji reactions pop-up
Performance: Making It Blazing Fast
Chat apps need to be fast. Like, really fast. Here's how I optimized everything:
1. Message List Optimization
- FlashList instead of FlatList (60% faster)
- Virtualization (only render visible messages)
- Memoization everywhere
- Lazy load images with blurhash
2. Database Performance
// Bad: Loads everything into memory
const messages = await database.collections
.get('messages')
.query()
.fetch();
// Good: Lazy loading with pagination
const messages = await database.collections
.get('messages')
.query(Q.where('chat_id', chatId), Q.sortBy('created_at', Q.desc), Q.take(50))
.observe();
3. Image Optimization
- WebP format (30% smaller than JPEG)
- Progressive loading (blur → full image)
- Aggressive caching
- CDN for all media
4. Network Optimization
- Request batching (group multiple API calls)
- Debounced search
- Optimistic updates (show immediately, sync later)
- Smart prefetching (preload next chat when scrolling)
5. Memory Management
- Cleanup old messages from memory (keep last 100)
- Unsubscribe from sockets when leaving chat
- Cancel pending uploads on unmount
- Proper event listener cleanup
Security: Not an Afterthought
I took security seriously from day one:
1. End-to-End Encryption
- Messages encrypted on device
- Keys never leave your phone
- Forward secrecy with key rotation
2. Secure Storage
- Encryption keys in Keychain (iOS) / Keystore (Android)
- Biometric authentication for app access
- Auto-lock after inactivity
3. Network Security
- Certificate pinning
- All API calls over HTTPS
- JWT tokens with short expiry
- Refresh token rotation
4. Privacy Features
- Last seen control
- Read receipts toggle
- Screenshot detection in stories
- Disappearing messages
Deployment & Infrastructure
Mobile App
# eas.json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./secrets/google-play.json"
},
"ios": {
"appleId": "your@email.com",
"ascAppId": "123456789"
}
}
}
}
Backend Infrastructure
- Servers: AWS EC2 (t3.medium) with auto-scaling
- Load Balancer: ALB with sticky sessions
-
Database:
- PostgreSQL on RDS (db.t3.large)
- MongoDB Atlas (M30)
- Redis ElastiCache
- Storage: S3 with CloudFront CDN
- Monitoring: Datadog + Sentry
- Logging: CloudWatch + Elasticsearch
Lessons Learned (The Expensive Way)
What Worked
✅ Starting with TypeScript saved me weeks of debugging
✅ Offline-first architecture was worth the extra complexity
✅ User testing caught UI issues I'd never have found
✅ Investing in animations made users perceive it as "faster"
✅ WatermelonDB for local storage was the right choice
What Didn't Work
❌ Trying to build video calls from scratch (just use WebRTC)
❌ Custom encryption (switched to battle-tested libraries)
❌ Optimizing too early (premature optimization is real)
❌ Storing images in base64 (file system is your friend)
❌ Building for iOS first, Android later (build for both from day one)
Biggest Surprises
🤯 WebRTC setup took 2 weeks (expected 2 days)
🤯 Users didn't care about fancy features, just speed and reliability
🤯 Dark mode added 30% more daily usage
🤯 Voice messages are WAY more popular than I thought
🤯 Battery drain from socket connections is a real problem
Mistakes I Made
- Not implementing rate limiting early - Got hit with spam attacks
- Underestimating push notification complexity - Spent a week on iOS notification handling
- Poor message queue design initially - Lost messages during network transitions
- Ignoring Android keyboard behavior - Chat input covered by keyboard for weeks
- Not planning for scale - Had to refactor database queries when hitting 10k users
The Metrics That Matter
After launching to friends and family (about 200 active users):
Performance:
- Average message delivery: 120ms
- P95 message delivery: 450ms
- App launch time: 1.2s
- Message list FPS: 58-60 (goal was 60)
- Crash-free rate: 99.8%
Engagement:
- Daily active users: 78%
- Average session length: 12 minutes
- Messages per user per day: 43
- Voice messages: 23% of all messages
- Story views: 2.3x more than posts
Technical:
- Bundle size: 38MB (iOS) / 42MB (Android)
- Database size after 1 month: ~85MB per user
- Network usage: ~8MB per hour of active use
- Battery drain: 2.5% per hour of active use
What's Coming Next
The app works great, but I'm far from done. Here's the roadmap:
Q1 2025:
- Message forwarding
- Group admin controls
- Custom chat wallpapers
- Voice/video message replies
Q2 2025:
- Channels (broadcast messages)
- Polls in groups
- Location sharing
- Contact cards
Q3 2025:
- Message scheduling
- Chat folders
- Advanced media editor
- Desktop app (Electron)
Experimental:
- AI message suggestions
- Real-time translation
- Blockchain-based identity
- Web3 wallet integration
Final Thoughts
Building this chat app taught me more than any tutorial or course ever could. The difference between a demo and a production app is enormous—it's the edge cases, the offline handling, the animations, the error states.
If I could give one piece of advice to someone building something similar: start with the hard parts first. Don't build the pretty UI before you figure out how messages actually sync. I wasted three weeks building a beautiful chat interface before realizing my message queue logic was fundamentally broken.
Is this app perfect? Hell no. There are bugs I haven't found yet, features I want to add, and performance improvements I know I need to make.
But it works. People use it daily. And that feeling when someone sends you a screenshot of your app in action? That's what makes this whole thing worth it.
Now stop reading and go build something.
That's a wrap 🎁
Now go touch some code 👨💻
Top comments (0)