Design Splitwise: The System Design Interview Walkthrough

May 27, 202612 min read
interview-prepcareerdsaalgorithms
Design Splitwise: The System Design Interview Walkthrough
TL;DR
  • Materialized balances table stores pairwise net amounts updated atomically with every expense write, giving O(1) balance reads instead of costly scans across expense_splits
  • Atomic three-table transaction (expenses + expense_splits + balances via ON CONFLICT DO UPDATE) prevents partial write states and guarantees financial consistency
  • Debt simplification is a greedy O(n log n) heap algorithm; the absolute minimum transaction count is NP-hard (Subset Sum equivalent), but greedy cuts 50-70% in practice
  • Shard by group_id to co-locate all expense and balance data for one group, keeping multi-row balance updates on a single shard
  • Redis cache-aside with a 30-second TTL absorbs home-screen balance reads; invalidate the expense creator's cache immediately on write for freshness where it matters
  • Kafka + async push delivery decouples notifications from the expense write path so FCM/APNs latency never blocks the transaction

You've used Splitwise. You know it's basically "a friend owes you $23.50 for tacos, another friend owes you $14 for drinks, and somehow the math never quite works out." The app makes this manageable. But spend five minutes thinking about concurrent writes, balance consistency, and debt simplification across millions of groups, and the simplicity evaporates fast.

Here is the full Splitwise system design interview walkthrough, from requirements to tradeoffs, in 45 minutes.


Clarify First, Then Build

Before you touch a whiteboard, nail the requirements. The interviewer is watching what you ask as much as what you design.

Functional requirements (the must-haves):

  • Users can create groups and add members
  • Any member can add an expense, specifying who paid and how to split it (equally, by exact amounts, or by percentage)
  • The system tracks pairwise net balances: A owes B $15, C owes B $8
  • Users can record a settlement to mark a debt as paid
  • Users can trigger "Simplify Debts" to reduce the number of transactions in a group
  • Everyone in a group gets notified when an expense is added or settled

Non-functional requirements:

  • 50M registered users, roughly 10M monthly active
  • Reads heavily outnumber writes, about 20:1. People check balances far more than they add expenses.
  • Balances are financial data. Consistency is non-negotiable.
  • P99 balance reads under 100ms
  • Notifications can be delayed by a few seconds. That is acceptable.

Out of scope for this session: in-app payments, receipt scanning, analytics dashboards.


Five Services. One Clear Responsibility Each.

Keep this simple. Five services, clean boundaries.

Client → API Gateway → [Expense Service | Balance Service | Settlement Service]
                                ↓                    ↓
                           PostgreSQL           Redis (balance cache)
                                ↓
                           Kafka → Notification Service → FCM / APNs

The Expense Service handles all writes: add, edit, and delete expenses. Every write goes through a single database transaction that touches three tables at once.

The Balance Service handles reads. Most of your traffic goes here. Users open the app, check who owes whom, and close it. They do this obsessively.

The Settlement Service records payments between users and adjusts balances.

PostgreSQL is the right primary store. Balance updates require ACID transactions. NoSQL is the wrong tool when you need guaranteed consistency across multiple rows in a single write. At 50M users Postgres handles this comfortably with sharding if needed. Shopify ran on MySQL at three orders of magnitude more load.

Redis caches materialized balance summaries so the home screen is a single cache hit, not a table scan. For a deep dive on this caching pattern, see the distributed cache system design walkthrough.

Kafka plus a Notification Service delivers push notifications asynchronously. Notifications can be a few seconds late. The expense write does not need to wait for them.

Splitwise five-service architecture showing write path in blue and read path in amber

Write path goes blue: API Server → Expense Service → Postgres → Kafka → Notification. Read path goes amber: Balance Service → Redis → Postgres fallback.


The Schema That Makes Balance Reads O(1)

Six tables. The interesting one is balances.

users (id, name, email, created_at) groups (id, name, created_by, created_at) group_members (group_id, user_id, joined_at) expenses (id, group_id, description, total_amount, currency, paid_by_user_id, created_by, created_at) expense_splits (id, expense_id, user_id, owed_amount) balances (user_id, counterpart_id, group_id, net_amount, updated_at, PRIMARY KEY (user_id, counterpart_id, group_id)) settlements (id, from_user_id, to_user_id, group_id, amount, created_at)

The balances table is the key design decision. You have two options:

  1. Recompute balances from expense_splits on every read
  2. Maintain a materialized balances table and update it synchronously during writes

Option 1 means every balance read requires an O(n) scan across potentially thousands of expense splits. With 10M active users checking balances frequently, that's too expensive.

Option 2 is the right call. The balances table stores the net amount A owes B within a given group. When you add an expense, you update it in the same transaction. Reads are O(1) key lookups.

The net_amount field uses a sign convention: positive means the row's user_id owes counterpart_id. Settlements subtract from it. If it hits zero, you can delete or keep the row. Either works.

Entity-relationship diagram showing the six Splitwise tables with the balances table highlighted as the materialized view

Six tables. The balances table (green) is a pre-computed materialized view. It exists so you never have to scan expense_splits on read.


The Write Path: One Transaction, No Partial States

This is where consistency lives or dies.

When a user adds an expense of $90 split equally three ways, the Expense Service runs this:

BEGIN; INSERT INTO expenses (group_id, description, total_amount, paid_by_user_id, ...) VALUES ($1, $2, 90.00, $payer, ...); INSERT INTO expense_splits (expense_id, user_id, owed_amount) VALUES ($expense_id, $user_b, 30.00), ($expense_id, $user_c, 30.00), ($expense_id, $user_d, 30.00); -- Update materialized balance for each split participant INSERT INTO balances (user_id, counterpart_id, group_id, net_amount) VALUES ($user_b, $payer, $group_id, 30.00) ON CONFLICT (user_id, counterpart_id, group_id) DO UPDATE SET net_amount = balances.net_amount + 30.00; -- ... repeat for user_c and user_d COMMIT;

All three writes commit atomically or not at all. If the server crashes between the splits insert and the balance update, the transaction rolls back. The database is never half-written. Unlike your friend who "forgot" to Venmo you, the database has no excuse.

To prevent races when two expenses land in the same group simultaneously, use SELECT ... FOR UPDATE on the balance rows before modifying them. This locks just those rows for the transaction, not the whole table.

After commit, the Expense Service publishes an event to Kafka. The Notification Service consumes it and pushes to all group members except the creator. Notifications are fire-and-forget. If a push fails, retry with exponential backoff. The main write path is already done.

Swimlane diagram of the Splitwise expense write flow showing the atomic transaction and async notification path

The write path as a swimlane. The transaction boundary wraps the three SQL operations. Notifications go async after commit; the main write path never blocks on push delivery.


The Read Path: One Cache Hit per Page Load

The home screen shows total owed and total you are owed. Two endpoints:

GET /users/{id}/balances
GET /groups/{id}/balances

The Balance Service checks Redis first. Cache key: balance:{user_id}. On miss, it reads from the balances table (a handful of indexed rows per user) and writes the result to Redis with a 30-second TTL.

A 30-second TTL means occasional stale reads, which is acceptable for this use case. The expense creator gets an immediate cache invalidation on write so their own view is always fresh. Other group members might see data that's 30 seconds old. For a social expense tracker, that's fine. For a banking app, it would not be.

If you want stronger guarantees, switch to write-through caching: update Redis inside the same transaction as the balance write. The tradeoff is a small latency increase on writes and added complexity if Redis is unavailable. Worth discussing, not always worth implementing.


How "Simplify Debts" Works

This is the algorithmic heart of the interview question. The feature takes a tangle of bilateral debts and collapses them into fewer transactions without changing anyone's total balance.

Example. A owes B $10. B owes C $10. Two transactions. With simplification: A pays C directly and B is zeroed out. One transaction.

The algorithm:

  1. Compute each person's net balance: credits received minus debts owed.
  2. Separate into creditors (positive net) and debtors (negative net).
  3. Greedily pair the largest debtor with the largest creditor. Transfer the smaller amount. Put the remainder back. Repeat.
import heapq def simplify_debts(net_balances: dict[str, float]) -> list[tuple]: # max-heaps via negation (Python heapq is min-heap) creditors = [(-amt, uid) for uid, amt in net_balances.items() if amt > 0] debtors = [(-amt, uid) for uid, amt in net_balances.items() if amt < 0] heapq.heapify(creditors) heapq.heapify(debtors) transactions = [] while creditors and debtors: credit, creditor = heapq.heappop(creditors) debt, debtor = heapq.heappop(debtors) credit, debt = -credit, -debt # restore positive values transfer = min(credit, debt) transactions.append((debtor, creditor, transfer)) if credit > debt: heapq.heappush(creditors, (-(credit - debt), creditor)) elif debt > credit: heapq.heappush(debtors, (-(debt - credit), debtor)) return transactions

O(n log n) where n is the number of people. For groups of 5 to 20 people this is instant.

Finding the absolute minimum number of transactions is NP-hard, equivalent to the Subset Sum problem. The greedy approach above does not always find the theoretical minimum. But in practice it cuts transactions by 50 to 70% and runs fast enough that the limitation does not matter. You should name both in the interview: "this is greedy, which is good enough in practice, but provably optimal is NP-hard."

Simplify Debts is user-triggered, not automatic. Splitwise makes it opt-in because reassigning debts can confuse people who agreed to pay each other directly. ("Wait, why does A owe C? We said A would pay me back.") The algorithm runs at query time against the current balance state. No writes happen until the user confirms the simplified plan.

Before and after debt graph showing 8 bilateral debts simplified to 4 transactions by the greedy algorithm

Before: 8 tangled transactions, everyone's Venmo is a war zone. After: 4 clean payments, same net balances for everyone. The greedy heap algorithm does this in O(n log n).


Where It Breaks Under Load

At 10M MAU with a 20:1 read/write ratio, peak load is roughly 5,000 expense writes per second (dinner rush across time zones) and 100,000 balance reads per second. PostgreSQL with read replicas handles the read side easily. Route all balance reads to replicas. Only writes go to primary. With Redis absorbing the home-screen reads, actual database QPS is much lower than 100K.

Shard by group_id. Every expense for a group lands on the same shard. Balance calculations stay local. The balances table shards cleanly by group too. This is the natural partition key for this data model.

Cross-group balances (what does A owe B summed across all groups?) require a fan-out query across shards. This is infrequent enough to cache aggressively. It does not need to be on the fast path.

Hot groups with hundreds of members adding expenses simultaneously become a write hot spot. Two options: advisory locks per group_id to serialize balance updates for that group, or a per-group write queue that turns concurrent writes into sequential ones. Advisory locks are simpler to implement and usually sufficient.

Notifications fan out at write time. A single expense in a 200-member group generates 199 push notifications. Kafka partitioned by group_id routes events to worker pools that batch-call FCM and APNs. At 10,000 pushes per second, a small worker fleet handles this.

Splitwise scaling architecture showing stateless API servers, Postgres primary with read replicas, Redis cluster, Kafka, and notification workers

The full scaling picture. Stateless API servers behind a load balancer. Writes go to Postgres primary, sharded by group_id. Reads hit Redis first, then replicas. Notifications go through Kafka to worker pools.


The Tradeoffs the Interviewer Wants to Hear

CP over AP for balances. Splitwise is a financial app. You choose consistency over availability here. A user who cannot read their balance for a few seconds during a network partition is a minor inconvenience. A user who sees a balance that ignores their last expense is a correctness bug. The push vs pull tradeoff is a useful frame: when you need strong guarantees, you pay with latency or availability.

Materialized versus on-demand balances. Materialized wins on reads but adds complexity to writes. Editing or deleting an expense requires reversing and reapplying balance changes inside a transaction, not just deleting a row. State it explicitly: this is a deliberate tradeoff, not an oversight.

Multi-currency. Store the raw amount and currency on every row. Also store a USD-equivalent computed at write time using the exchange rate at that moment. Never recalculate at read time using live rates. Balances would shift every time exchange rates moved, which is confusing and wrong. Your group trip to Tokyo should not look more expensive every time the yen moves.

Simplify Debts: server-side versus client-side. You could run the greedy algorithm on the client. The problem is cache staleness: different clients might have slightly different balance snapshots and produce inconsistent simplified plans. Run it server-side against the authoritative balance table. Cache the result per group with a short TTL.

A similar consistency-under-concurrency problem appears in the Ticketmaster design: two users claiming the same resource simultaneously, and why database-level locking beats application-level optimism for correctness-critical writes.


The 45-Minute Clock

  • 0-5: Requirements. Functional (groups, expenses, splits, settlements, simplify). Non-functional (50M users, 20:1 read/write, strong consistency for balances, eventual for notifications).
  • 5-15: Five-box architecture. Expense, Balance, Settlement services. Postgres, Redis, Kafka, Notification service. Why Postgres over NoSQL.
  • 15-25: Data model. Six tables, the materialized balances table, sign convention, PRIMARY KEY choice.
  • 25-35: Core flows. Expense write as single atomic transaction with ON CONFLICT DO UPDATE. Redis cache-aside on reads. Simplify Debts greedy algorithm and its NP-hard caveat.
  • 35-42: Scaling. Read replicas, group_id sharding, advisory locks for hot groups, Kafka fan-out.
  • 42-45: Tradeoffs. CP vs AP, materialized vs computed, multi-currency, server-side simplification.

Splitwise System Design: What to Get Right

  • Materialized balances, not on-demand scans. Update them atomically in the same transaction as the expense. O(1) reads, guaranteed consistency.
  • SELECT FOR UPDATE prevents races when concurrent expenses hit the same group's balance rows.
  • Simplify Debts is greedy O(n log n). Provably optimal is NP-hard. Name both.
  • Shard by group_id to keep all expense and balance data for a group co-located.
  • Notifications are async through Kafka. The write path never blocks on push delivery.
  • Multi-currency: store exchange-rate-locked USD equivalents at write time.

Narrating all of this coherently under interview pressure is its own skill, separate from knowing the architecture. SpaceComplexity runs voice-based system design mock interviews with rubric feedback so you can find out how your explanation lands before the real session.


Further Reading