When I started building PerSplit — a group expense splitting application — I quickly learned that the hardest part of backend development is not writing CRUD endpoints. The real challenges are data consistency, real-time synchronisation, and algorithm design. In this article I walk through the key engineering decisions I made while building a production-grade Node.js and MongoDB backend for a cross-platform Flutter app.
The Problem: Why Existing Apps Fall Short
Most expense-splitting apps — like Splitwise — have a critical UX flaw: they show stale balances and require manual refresh. In a group setting where 5+ people are simultaneously logging expenses from a trip, real-time updates are not a nice-to-have; they are essential. I also noticed that Indian users specifically need UPI-native payment flows, something generic international apps completely miss.
My goal for PerSplit was clear: a backend that handles group expense tracking, supports multiple split methods (equal, percentage, exact), provides real-time sync via WebSockets, and generates UPI QR codes for instant settlement.
Architecture Overview
The stack I chose was deliberate:
- Node.js + Express.js — non-blocking I/O is ideal for WebSocket-heavy workloads
- MongoDB — flexible document model suits evolving expense schemas
- Socket.IO — room-based WebSocket management with automatic reconnection
- JWT — stateless authentication across mobile and web clients
- Flutter — cross-platform mobile frontend consuming the REST + WebSocket API
I structured the backend using a clean Controller → Service → Repository pattern, which I cover in more depth in my article on Clean Architecture in Node.js.
The Net-Settlement Algorithm
The most intellectually interesting part of PerSplit is the minimise-transactions algorithm. Naively, if Alice owes Bob ₹300 and Carol ₹200, and Bob owes Carol ₹150, you end up with 3 transactions. The optimal solution reduces this to 2.
The algorithm I implemented works as follows:
- Compute each participant's net balance (total paid − total owed)
- Separate participants into two lists: creditors (positive balance) and debtors (negative balance)
- Greedily match the largest debtor with the largest creditor
- Settle the minimum of the two amounts, remove the settled party, continue
// Simplified net settlement — O(n log n)
function minimiseTransactions(balances) {
const creditors = [], debtors = [];
for (const [userId, amount] of Object.entries(balances)) {
if (amount > 0) creditors.push({ userId, amount });
else if (amount < 0) debtors.push({ userId, amount: -amount });
}
creditors.sort((a, b) => b.amount - a.amount);
debtors.sort((a, b) => b.amount - a.amount);
const transactions = [];
let i = 0, j = 0;
while (i < creditors.length && j < debtors.length) {
const settle = Math.min(creditors[i].amount, debtors[j].amount);
transactions.push({
from: debtors[j].userId,
to: creditors[i].userId,
amount: settle
});
creditors[i].amount -= settle;
debtors[j].amount -= settle;
if (creditors[i].amount === 0) i++;
if (debtors[j].amount === 0) j++;
}
return transactions;
}
This reduces average transaction count by 40–60% compared to a naïve per-expense settlement approach.
Real-Time Updates with Socket.IO
Every group in PerSplit maps to a Socket.IO room. When any user adds, edits, or deletes an expense, the server broadcasts a diff to all connected group members in under 50ms on a typical network.
// On expense creation
io.to(`group:${groupId}`).emit('expense:created', {
expense: newExpense,
updatedBalances: await computeGroupBalances(groupId)
});
I used socket.join() on auth so that each user automatically joins all their groups on connection — no per-request join needed. This reduced socket message overhead by roughly 30%.
UPI QR Code Generation
For settlement, I generate UPI deep-link URIs server-side and encode them into QR codes:
const upiLink = `upi://pay?pa=${payeeVpa}&pn=${payeeName}&am=${amount}&cu=INR&tn=PerSplit+Settlement`;
const qrDataUrl = await QRCode.toDataURL(upiLink, { width: 300 });
These QR codes are sent as base64 data URLs and rendered natively in Flutter using the qr_flutter package. No external payment gateway needed — works with any UPI-compatible app (Google Pay, PhonePe, Paytm, etc.).
Key Lessons for Backend Developers
- Design your data model around your queries, not around your entities. MongoDB's flexible schema let me embed expense items and participant shares in a single document, avoiding expensive joins.
- WebSocket rooms are cheap. Don't be afraid to create one per resource — the memory footprint is minimal.
- Validate at the boundary. All incoming request bodies go through
express-validatormiddleware before hitting any service layer. - Idempotency keys matter. Duplicate expense submissions (due to network retries) must be detected and rejected.
Related: How I implemented JWT authentication in PerSplit's Node.js backend — access tokens, refresh rotation, and secure cookie storage.
What I Would Do Differently
If I rebuilt PerSplit today, I would add event sourcing for the expense ledger instead of mutable documents. This would give us a complete audit trail and enable time-travel queries ("what were the balances on day 3 of the trip?"). I would also move the settlement algorithm into a dedicated microservice to allow horizontal scaling.
Conclusion
Building PerSplit taught me that backend engineering is fundamentally about trade-offs: consistency vs. performance, simplicity vs. flexibility, monolith vs. microservices. The decisions I made here — MongoDB document design, Socket.IO room strategy, the greedy settlement algorithm — were all shaped by understanding the actual usage patterns of the app.
If you are a recruiter or engineering team looking for a backend-focused Node.js developer who can design real systems, not just connect endpoints to databases — get in touch.