Tanay Karnik
788 words, 4 minute read
Building PeerSplit: A Free, Peer-to-Peer Alternative to Splitwise
NOTE

This was also posted as an X thread.
View the X thread

I built PeerSplit—a free, peer-to-peer alternative to Splitwise—in just two weeks, from idea to launch!

PeerSplit is a local-first app for splitting group expenses. It works offline, is 100% free and private, and doesn’t require signups or any personal data.

Here’s how I built it and everything I learned along the way.

Why PeerSplit?#

I’ve relied on Splitwise for years to manage expenses with friends and roommates. But with its recent daily transaction limits and intrusive ads, it’s become frustrating to use.

I wanted a free, privacy-first alternative that didn’t require servers to store or sync data. I wouldn’t trust my expenses with a third-party server.

After working on peer-to-peer, local-first projects like a workout tracker and a distraction-free writing app, I realized I could apply the same approach to expense splitting.

That’s how PeerSplit was born. I started designing the app.


Building the UI with Nuxt + Nuxt UI#

I suck at designing UIs.

A few months ago, I wouldn’t have thought I could build a UI as polished as PeerSplit’s (some people even say it has better UX than Splitwise).

So, how did I manage to do it? Nuxt UI.

Nuxt UI is gorgeous, and it has amazing developer experience (DX).

It also comes with other useful Nuxt modules like @nuxt/icon, @nuxtjs/tailwindcss, and @nuxtjs/colormode.

All I had to do was pick a primary color, and I had all the components I needed—icons, dark mode, and everything else—to bring PeerSplit’s UI together.


cr-sqlite for local syncing 🔗#

For local data storage and syncing, I went with cr-sqlite, which builds on wa-sqlite and uses CRDTs (conflict-free replicated data types).

CRDTs are great for peer-to-peer systems because they handle conflicts automatically—so users can work offline, and when they reconnect, changes merge seamlessly.

However, cr-sqlite doesn’t sync changes over the network by itself. It only provides APIs to export and merge changes. You need to manually send those changes between devices.


Gun.js for peer-to-peer syncing 🌐#

To handle secure peer-to-peer syncing, I used Gun.js, which provides a peer-to-peer, distributed graph database.

Gun’s gun.user API lets me create encrypted nodes for each group. All changes for a group are stored on that node and synced only with group members, keeping everything private.

When a user performs an action, I take the changes exported from cr-sqlite and push them to the node. When the user comes back online, Gun syncs the new changes, keeping everyone up-to-date.

Implementing this in a performant way was tricky. For more details, you can check out the source code here.


Simplifying debts 💰#

One cool feature of Splitwise (and now PeerSplit) is “simplifying debts.”

Here’s how it works: If A owes B and B owes C, A can just pay C directly to potentially reduce the number of repayments.

In PeerSplit, I first calculate the net balance for each person. Then I sort those balances and suggest payments one by one to bring at least one person’s balance to zero each time.

This sorting ensures that everyone sees the same repayments on their devices.

It’s not 100% optimal (some groups might still have up to n-1 payments), but it works well in most cases.

An optimal solution would be exponential to calculate and would only save a few payments. So this was the best tradeoff for simplicity and speed!

export const groupGetPayments = (group) => {
const payments = [];
const balances = Object.entries(groupGetBalances(group)).map(([a, b]) => [
b,
a,
]);
balances.sort();
let i = 0,
j = balances.length - 1;
while (i < j) {
if (balances[i][0] === 0) {
i++;
} else if (balances[j][0] === 0) {
j--;
} else if (-balances[i][0] > balances[j][0]) {
payments.push({
from: balances[i][1],
to: balances[j][1],
value: round(balances[j][0]),
});
balances[i][0] += balances[j][0];
balances[j][0] = 0;
} else {
payments.push({
from: balances[i][1],
to: balances[j][1],
value: round(-balances[i][0]),
});
balances[j][0] += balances[i][0];
balances[i][0] = 0;
}
}
return payments;
};

PWA#

I wanted PeerSplit to function as an offline app, but I didn’t want to go through the hassle of building multiple native applications or dealing with the lengthy process of publishing them on app stores. So, opting for a Progressive Web App (PWA) was the clear choice.

A PWA combines the best of web and mobile apps, allowing users to install it on their devices while still enjoying offline capabilities.

To transform my Nuxt app into a PWA, I used vite-pwa. I designed an SVG logo in Figma and used it to generate all the necessary PWA assets through vite-pwa’s asset generator.

After that, I configured the PWA manifest, and vite-pwa automatically set up the service worker for me.

I configured Nuxt to prerender all the routes, so that my app could fully function offline.


And that’s a wrap!#

PeerSplit is now live! Check it out here: https://peersplit.app

It’s fair-source, so feel free to contribute or submit feature requests on GitHub.

tanayvk
/
peersplit
Waiting for api.github.com...
00K
0K
0K
Waiting...

I’m also launching on Product Hunt tomorrow, so I’d appreciate your support! https://producthunt.com/products/peersplit