The Tech Noob
blockchainsystem-designlearnings

Bitcoin Portfolio App: Pushing Data Sync to the Browser

How I beat public API rate limits on an 8€ budget: A look at Go microservices, shared chain caching and client-side blockchain sync.

May 10, 202616 min read

Building At Profit

During the past year I have been consuming a ton of personal finance content online (can't stress how good Ben Felix's channel is) and I grew my interest in the IT systems supporting all the global economy. From an engineering perspective, the sheer scale, traceability, and reliability of banking and brokerage systems fascinated me.

Of course looking at this content it's extremely easy to just fall down the rabbit hole of crypto. It might not be the best strategy for finances, but the blockchain financial applications are definitely interesting from a technological perspective. After looking at a few open APIs I decided to take on a bit of side project challenge.


The idea behind it

Looking around on the internet, you'll find many places that can tell you what returns bitcoin in general has made over a certain period. To calculate exactly your returns however you'll need to either run the numbers yourself or pay for a service that'll do that for you. Some apps like CoinTracker already exist and do a fairly good job at connecting to a wide range of platforms to calculate your personal returns for the upcoming tax season.

However, I decided to put a bit of my time and build an alternative that was easier to maintain by not having an integration to each different exchange. If I only want to measure bitcoin returns, and I know all the public wallet addresses I've used in my transactions, I should then be able to obtain the data I need from the blockchain using publicly available APIs and run the numbers in an automated way.

Trying to generalize it a bit, and getting some of my international friends to try it, I would also add support for different currencies in the app.


Choosing the right services

Even though I can get passionate about side projects, I also want to keep things as cheap as possible. The most popular blockchain API is mempool.space, and upon checking their monthly plans I was very grateful I only wanted to read transactions from a limited number of addresses. Their rate limits ended up influencing the system architecture, but there was no way around that without leaving a very significant chunk of my salary into this project.

For getting currency exchange data luckily there's services like Frankfurter that provide daily exchange rates across currencies totally for free. Their documentation is great so when I came across that API it was a no-brainer.

Wanting to explore making a whole side project out of this, I also looked at how to setup mailing, payments and hosting options.

Hetzner

I looked briefly at Hetzner's Webhosting S package, but decided against it pretty quickly. The mailbox side of that offering didn't win on price or setup effort compared to a dedicated email provider, and I already knew I'd want a real Docker host for the app anyway. So I went with a Cloud VPS instead: a CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe) running Ubuntu, plus DNS and the domain itself registered through Hetzner.

That single box is the entire production footprint — frontend, three Go services, Postgres (a relational database), nginx (a web server and reverse proxy), certbot (automated TLS certificate management), and Listmonk (a self-hosted newsletter tool). The setup is the usual VPS routine: harden SSH access, open only ports 22/80/443, install Docker, point DNS records at the machine, and bring the compose stack up behind nginx with Let's Encrypt for HTTPS. With only 4 GB of RAM you also want some swap space and tight container memory limits, otherwise Postgres and Next.js will happily fight each other for resources.

Brevo

For outbound mail I ended up using Brevo only as an SMTP relay (the service that actually delivers outgoing emails to recipients' inboxes). Their free plan is generous enough for a side project (300 emails per day), which covers password-reset traffic and the occasional newsletter without paying for a transactional email product.

The app itself talks to a self-hosted Listmonk instance for templates, lists, and transactional sends. Listmonk then ships those messages through Brevo's SMTP endpoint. That split keeps campaign tooling under my control while Brevo handles deliverability and the boring SMTP reputation problem.

Neo

Mailbox accounts on the domain came from Neo. It's cheap, and the setup is straightforward: add a couple of DNS records (MX records pointing at Neo's mail servers plus an SPF record for email authentication) and wait for propagation. Once that was done, I had proper @at-profit.de-style addresses for support and admin without running my own mail stack.

Paddle

Billing is handled through Paddle. I didn't want to become a merchant of record for EU VAT myself, so using a MoR (merchant of record) that also gives you checkout, customer portal, and subscription webhooks was the pragmatic and standard choice.

In practice the flow is: the frontend asks a small payment service for a checkout transaction for one of four subscription prices (basic/full subscription × monthly/yearly), Paddle's overlay checkout takes over, success lands on a /billing page, and Paddle webhooks update the user's entitlement in Postgres. The account page can also open Paddle's customer portal when someone wants to change payment details or cancel.

Google Auth Platform

To reduce signup friction, I added Google login via the Google Auth Platform Identity Services button. On the backend this is standard OIDC verification (OpenID Connect — an authentication layer built on top of OAuth 2.0) against a configured client ID.

Email/password still exists for people who don't want to use Google, but one-click registration made onboarding much less annoying when I was asking friends to try the app.

Why not Cloudflare?

I already know Cloudflare reasonably well from work and other projects (such as this blog!), so for this project I deliberately stayed away from it. The goal was to learn a different path end-to-end: Hetzner DNS, a single European VPS, certbot for TLS, and rate limiting on my own reverse proxy instead of a CDN layer. For a low-traffic side project that was enough, and it forced me to actually own the TLS and availability story instead of outsourcing it by default.

After setting all of this up, the total cash burn of the project was a whopping 8€ a month.


The architecture and project setup

When building things on the side, I usually want to take the opportunity of exploring technologies that I don't get exposure to at my job. Going into this, I had the idea to build something in Golang so that ended up being my choice for the programming language of the entire backend.

Backend

The production stack is a Docker Compose deployment with a production overlay. Everything sits behind nginx with Let's Encrypt for TLS certificates via certbot. On first bootstrap there's an init script that will also generate a 4096-bit Diffie–Hellman parameter file (a one-time cryptographic step that strengthens TLS handshakes — it's slow, so you only run it once):

docker run --rm \
  -v atprofit_dhparam:/etc/ssl/dhparam \
  frapsoft/openssl:latest \
  dhparam -out /etc/ssl/dhparam/dhparam.pem 4096

There are three Go services sharing one Postgres database:

  • auth-service: registration/login, Google OIDC, current user profile, preferred currency, account deletion, and the entitlement/capability view the UI uses as a feature-flag system (free / basic / full quotas for addresses, history depth, CSV export, tax, premium summary).

  • payment-service: Paddle checkout sessions, customer portal sessions, and webhook verification that maps price IDs back to plan tier + billing interval.

  • atprofit (the transaction / portfolio service): guest public endpoints, authenticated address CRUD, transaction batch ingest, sync-state persistence, portfolio summary/transactions/CSV, and tax metadata + estimates. This is also where garbage collection runs.

Alongside those: Postgres 17, Listmonk (using the same postgres instance as the other backend services), nginx, and a long-running certbot renew loop. Production containers use restart: always and tight memory/CPU caps so the CX23 stays responsive instead of running out of memory at the worst moment.

Frontend

The UI is a Next.js App Router app (React 19, Tailwind, Recharts for charts, Sonner for toasts). There are two intentional product modes:

  1. Guest flow on the public site. Will perform live mempool.space read queries for a limited history window, and has currencies limited to EUR/USD.

Guest homepage with address demo

  1. Authenticated flow. A logged in user will have access to a main dashboard with a visual graph of their addresses, plus access to sections for seeing transactions, tax estimations, and account details. After login, a browser sync worker fetches history and exchange inputs, posts batches to update the backend, and authenticated pages only read stored data from our backend.

Authenticated dashboard after sync

The sync path is the interesting bit. A Dedicated Worker (sync.worker.ts) runs the fetch loop in sync-runtime.ts (mempool pages, BTC/EUR prices, Frankfurter for display currencies). A main-thread manager (sync-worker.ts) owns auth, flushes batches to POST /v1/transactions/batch, and only then persists sync state with initial_sync_complete. If the worker fails to start (Turbopack stubs are a known footgun, production builds force webpack), it falls back to the main thread. After the first full sync it keeps polling about every 15 minutes.

User flow in short: register → optional onboarding plan pick → add first address → watch the sync status bar → dashboard/transactions unlock when selected addresses finish initial sync → tax stays behind the premium subscription wall.

Addresses page during initial sync

Repository setup

I kept the frontend in a separate git repository and wired it into the main infra/backend repo as a submodule-style checkout. The idea was simple: deploy the hosting Docker stack from one place, while letting the Next.js app move at its own pace.

Advantages

  • Frontend history and releases stay independent from Docker/nginx/Go churn.
  • The main repository stays focused on services, compose files, and deployment scripts.
  • Production builds just COPY the populated frontend/ working tree into the image, so the deploy machine doesn't need a clever monorepo build graph.

Disadvantages

  • A bare clone of the parent repo without initializing/checking out the frontend leaves you with an empty directory and a broken Docker build. So you need to make sure to always do that.

  • Docs and muscle memory have to remember two remotes, two branches, and "did I bump the submodule pointer?"

  • Day-to-day local work is slightly more awkward than a true monorepo, especially when a backend contract change and a UI change need to land together.

For a solo side project the trade-offs were acceptable.

Scalability-focused decisions

Mempool.space rate limits are the real constraint, not CPU. Two decisions mattered more than fancy horizontal scaling:

  1. Do the heavy sync in the browser. The user's own machine paginates address history and collects exchange inputs. The backend mostly upserts batches and recomputes accounting. That way there's not an imposed limit on the entire backend services and the total API request volume is distributed across the user's IP addresses.

  2. Treat Postgres as a shared chain cache. Transactions live on a shared addresses / transaction_records graph. Users attach through user_addresses. If someone else already synced a particular Bitcoin address, a new user can reuse the stored history instead of hitting the mempool.space API again.

Authenticated dashboard and tax pages never go live to mempool, they only read stored rows. Guest mode stays live (with a short TTL cache) so the demo experience still works without an account.

Garbage collection

Deleted accounts and archived addresses shouldn't live forever on a 40 GB disk. The atprofit service runs a periodic garbage collection loop (in production I set it to weekly). It hard-deletes soft-deleted users past the retention window, cleans archived shared addresses (and their transaction rows) when nothing points at them anymore, and drops expired entries used for guest caches and sync blobs.

This is not a distributed janitor service, it's a goroutine (a lightweight thread managed by Go's runtime) with a timer on the one machine that owns the data. For this project that's exactly the right amount of automation.

Rate limits and restart policy

Availability on a cheap VPS is less about multi-region failover and more about not letting a heavy user cause downtime.

At the edge, nginx applies per-route rate limits (stricter on authentication, payment and webhook endpoints than on bitcoin reads). Inside the Go services there's a second per-IP RPM (requests per minute) budget, with authenticated atprofit routes getting a larger multiple because the dashboard fans out into several stored-data reads. All of that is in-process memory, fine for one replica, but useless if you pretend you've already scaled horizontally.

Containers run with restart: always, modest CPU/memory caps, and Postgres tuned down for a small host (max_connections kept low, shared buffers sized for ~1 GB RAM). If a process dies, Docker brings it back. If someone scrapes too hard, they get 429s instead of taking the whole stack with them.


Tax calculation module

This one was a fun extra feature to build in. Living in a different country from the one I grew up in, and noticing that most online financial content focuses on US tax rules, I thought it'd be interesting to add a tax estimation module that shows how different countries tax crypto profits differently.

After researching several western countries, I identified a small set of common shapes in how taxes are structured:

  • Flat net gains (many EU countries, UK-style annual exempt amounts, etc.)

  • Progressive net gains or progressive ordinary income (Spain)

  • Holding-period income / cliff gains (Germany, Portugal, Luxembourg-style patterns)

  • Split short/long-term capital gains (US)

  • Profiled / exemption-style regimes

  • Deemed-return wealth tax (Netherlands)

  • Gains plus stamp duty or wealth add-ons (Italy, Norway)

  • Monthly disposal aggregation (Brazil-style)

In the backend those became about eleven MethodFamily values. Each jurisdiction-year is a declarative Rule object (defining rates, brackets, required form fields, and whether the rule is enacted or projected).

The engine always starts by preparing FIFO lots (First-In, First-Out, an accounting method where the earliest acquired coins are considered sold first) from the full stored transaction history, then switches on the method:

const (
	MethodFlatNetGains            MethodFamily = "flat_net_gains"
	MethodProgressiveNetGains     MethodFamily = "progressive_net_gains"
	MethodProgressiveIncome       MethodFamily = "progressive_income"
	MethodHoldingPeriodIncome     MethodFamily = "holding_period_income"
	MethodHoldingPeriodCliffGains MethodFamily = "holding_period_cliff_gains"
	MethodSplitTermCapitalGains   MethodFamily = "split_term_capital_gains"
	MethodProfiledTaxation        MethodFamily = "profiled_taxation"
	MethodDeemedReturnWealth      MethodFamily = "deemed_return_wealth"
	MethodGainPlusStampDuty       MethodFamily = "gain_plus_stamp_duty"
	MethodGainPlusWealth          MethodFamily = "gain_plus_wealth"
	MethodMonthlyDisposalsGains   MethodFamily = "monthly_disposals_gains"
)

Extra inputs (filing status, income band, local quirks) are request-scoped and intentionally not stored. The UI can explain which taxable events were triggered and which acquisition lots were matched, but the whole thing remains an estimate.

Once the rules were set, I used AI to stress-test the setup against thirty-plus country rule sets. I used AI heavily to research and draft jurisdiction summaries into a structured markdown/config document, then translated that into typed Go rules and tests. This way, if the project becomes a longer-term maintenance effort, I wouldn't have to manually keep up with every jurisdiction, I could set an LLM to loop through each country's yearly tax rule updates for draft reviews.

Tax simulator with country and year selected

Entitlements gate the feature the same way everything else is gated:

case activePaidPlan && normalized.PlanTier == PlanFull:
	return Capabilities{
		UnlimitedAddresses:    true,
		FullHistory:           true,
		CanExportCSV:          true,
		CanUseTaxCalculations: true,
		CanViewPremiumSummary: true,
		CanViewRealizedProfit: true,
	}

Limitations

Keeping up with the public addresses your external wallet generates is not exactly user-friendly. If you rotate addresses often and your exchange or wallet provider doesn't expose a master extended public key (xpub), managing individual public addresses manually becomes a nightmare. While an xpub natively solves address rotation by letting the app derive your entire history, getting users to locate or trust an app with their xpub is a high friction point.

For very active wallets, pulling the full history from the mempool.space API can take a few minutes. That was predictable from day one given their rate limits and page size constraints. The worker progress UI makes it tolerable, and shared caching helps the second user syncing the same address, but the first sync of a busy address will never feel instant on a free public API.

Tax estimates and overall spending figures also can't be fully correct from chain data alone. You see on-chain fees (the transaction costs paid to the Bitcoin network), but not the trading fees, spreads, or withdrawal fees an exchange charged on top. You also don't see off-chain context like "this UTXO was a gift" (a UTXO, Unspent Transaction Output, is a discrete chunk of bitcoin that can be spent in a future transaction) or "that disposal was a transfer to myself." So the tax module is a structured estimator with explanations, useful for orientation, but not a replacement for an accountant or a brokerage-grade importer.

Why this would never pass as corporate infrastructure (and why that's fine)

This setup is proudly single-VPS. Rate limits are per-process maps. Caches are local. There's one Postgres instance, no replica, no message queue, no multi-region story, no SOC2 binder on the shelf. Deployment is Docker Compose and a Makefile. Email is "Listmonk + free SMTP." Billing correctness depends on webhook secrets being configured properly. Horizontal scale would break assumptions immediately.

At a company you'd want managed databases, shared rate limiting, proper secret management, staging environments that mirror production, and a support model for when tax law changes mid-season. You'd also probably pay for higher mempool API tiers instead of architecting around free-tier politeness.

For a side project whose goal was to experiment on the minimum budget possible, those absences are the point. The interesting engineering was elsewhere: splitting guest vs authenticated data paths, pushing sync to the browser, sharing chain history across users, encoding many tax regimes as one method table, and keeping monthly costs low enough that the project could exist at all.


Thank you for reading! Hope you found this interesting. I did decide to take down At Profit and redirect those 8€ toward good coffee, but I'm happy to share all learnings here.

Feel free to reach out if you liked this article! :)

Thanks for reading! If you found this helpful, feel free to share it.

More articles