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:

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:

  1. Compute each participant's net balance (total paid − total owed)
  2. Separate participants into two lists: creditors (positive balance) and debtors (negative balance)
  3. Greedily match the largest debtor with the largest creditor
  4. 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

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.