The feature that’s giving me the most trouble right now isn’t some fancy AI or 3D animation. It’s a “basic” live chat system glued to geolocation-based matching and a rating flow that looks trivial in the UI. Under the hood, tying all of this together has been one of the most technical things I’ve shipped so far.
This post is a deep dive into how the app is structured, the decisions that aged well (and the ones that didn’t), and the surprising complexity behind “just show nearby users and let them chat.”
High-level architecture
At a high level, the app is:
Frontend: React + TypeScript SPA, talking to a JSON API over HTTPS.
Backend: Node.js (Express) API server with WebSocket support for live chat.
Data: PostgreSQL for relational data (users, sessions, ratings), Redis for ephemeral presence / active sessions.
The flow:
User signs in → frontend stores a short-lived access token.
Frontend sends location (coarse, not precise) to the backend.
Backend matches user with nearby users based on a distance query.
When a match is confirmed, the backend creates a chat room and establishes a WebSocket channel for both sides.
After the session, users submit a rating, which feeds into a simple reputation system.
Nothing exotic, but the devil is in the details of those three features: geolocation matching, live chat, and ratings.
Geolocation matching: more edge cases than expected
The goal: show users other users “nearby” and match them into sessions. Sounds like a single SQL query, right?
In practice, it involved:
Normalizing and storing locations as latitude/longitude.
Using Postgres with the earthdistance / cube extensions (or a geospatial index) to query by distance.
Handling users with no location / denied permission gracefully.
A simplified version of the core matching query looks like this (roughly):
ts
// Pseudo-DAO: find users within radius (km)
async function findNearbyUsers(userId: string, radiusKm: number) {
const userLocation = await db.query<{
lat: number;
lng: number;
}>(
,
SELECT lat, lng
FROM user_locations
WHERE user_id = $1
[userId]
);
if (!userLocation.rowCount) {
return [];
}
const { lat, lng } = userLocation.rows[0];
const result = await db.query(
,
SELECT u.id, u.name, ul.lat, ul.lng
FROM users u
JOIN user_locations ul ON ul.user_id = u.id
WHERE u.id != $1
AND earth_distance(
ll_to_earth($2, $3),
ll_to_earth(ul.lat, ul.lng)
) <= $4 * 1000
ORDER BY earth_distance(
ll_to_earth($2, $3),
ll_to_earth(ul.lat, ul.lng)
) ASC
LIMIT 20
[userId, lat, lng, radiusKm]
);
return result.rows;
}
The hard parts weren’t just the math; they were:
Deciding how to bucket “nearby” to avoid constantly recalculating for every move.
Handling stale locations when someone moves but doesn’t reopen the app.
Designing fallbacks when location permissions are denied.
If starting over, there would probably be a clearer separation between “location collection” and “matching,” maybe even separate services or queues for heavier recalculations.
Live chat: WebSockets meet reality
Live chat looked straightforward on paper: WebSocket server, rooms, message broadcast. In reality, the tricky part was handling disconnects, reconnections, and keeping the UI in sync with backend state.
The stack:
WebSocket server running alongside the Node.js API.
Redis pub/sub to broadcast messages across multiple server instances.
A simple protocol: JOIN_ROOM, MESSAGE, LEAVE_ROOM, TYPING, etc.
Here’s a simplified version of the server-side message handler that caused the most headaches:
ts
// Simplified WebSocket message handler
type IncomingMessage =
| { type: 'JOIN_ROOM'; roomId: string }
| { type: 'LEAVE_ROOM'; roomId: string }
| { type: 'MESSAGE'; roomId: string; text: string };
function createWebSocketHandler(ws: WebSocket, userId: string) {
const currentRooms = new Set();
ws.on('message', async (raw) => {
let msg: IncomingMessage;
try {
msg = JSON.parse(raw.toString());
} catch {
// ignore invalid messages
return;
}
switch (msg.type) {
case 'JOIN_ROOM': {
currentRooms.add(msg.roomId);
await subscribeUserToRoom(userId, msg.roomId);
break;
}
case 'LEAVE_ROOM': {
currentRooms.delete(msg.roomId);
await unsubscribeUserFromRoom(userId, msg.roomId);
break;
}
case 'MESSAGE': {
if (!currentRooms.has(msg.roomId)) {
return; // ignore messages sent to rooms user hasn't joined
}
const saved = await saveMessage({
roomId: msg.roomId,
senderId: userId,
text: msg.text,
});
await broadcastToRoom(msg.roomId, {
type: 'MESSAGE',
payload: {
id: saved.id,
senderId: userId,
text: saved.text,
createdAt: saved.createdAt,
},
});
break;
}
}
});
ws.on('close', () => {
// Cleanup all rooms on disconnect
for (const roomId of currentRooms) {
void unsubscribeUserFromRoom(userId, roomId);
}
currentRooms.clear();
});
}
What made this hard:
Ensuring messages aren’t processed for rooms the user “left” mid-flight.
Cleaning up subscriptions on unexpected disconnects.
Handling duplicate joins / reconnects without creating ghost sessions.
If rebuilding, there would likely be a more explicit state machine for connection and room membership, instead of ad-hoc flags and sets.
Rating system: deceptively simple
The rating system is “just a 1–5 star rating after each session,” but it quickly touches:
Data modeling (who rates whom, per session).
Preventing duplicate ratings for the same session.
Aggregating ratings efficiently for profile display.
The current approach:
Each session has an id.
Each rating is tied to session_id, rater_id, and ratee_id.
A unique constraint on (session_id, rater_id) to prevent duplicates.
A materialized view / cached aggregate to show average rating and count on profiles.
The part that took time was realizing the need for proper constraints and avoiding over-calculating aggregates on every profile view.
What would be done differently
If starting this project again:
Split “matching” and “chat” into separate modules (or services) with clear boundaries.
Invest in more explicit modelling of connection state for WebSockets.
Add more observability (logs, metrics, tracing) earlier, especially around chat and matching.
Prototype the rating system as an event stream first, then derive aggregates from events.
This has been the most technical article written so far on this project, but it matches how the work actually feels: lots of little decisions that add up to either a clean system or a brittle one.
Your turn (and the repo)
If you’ve built something similar—geolocation, matching, or live chat—there’s a lot that can be learned from your experience.
What would you do differently in this architecture, or what’s one pattern you’ve used for live chat / matching that worked especially well?
Top comments (0)