The Moment Everything Clicked
I was standing in a Tokyo 7-Eleven at 11 PM, jet-lagged and frustrated, trying to pry a plastic SIM tray out of my phone with a paperclip I'd borrowed from the cashier. The physical SIM card I'd bought at the airport wasn't activating. My hands were shaking from too much airplane coffee. And I thought: this is 2024. Why am I still doing this?
That moment — standing under fluorescent lights, fumbling with a piece of plastic smaller than my fingernail — is where this whole thing started. Not with a pitch deck. Not with a market analysis. With genuine, bone-deep frustration at how absurdly broken the mobile connectivity experience still is for travelers.
Six months later, I shipped the first version of RoamLink — a global eSIM platform that lets travelers buy and activate data plans in 190+ countries without touching a physical SIM card. This is the story of how I built it, why I chose the stack I did, and what I'd do differently.
Building something from scratch — one commit at a time
What We Actually Built
RoamLink is, at its core, an eSIM provisioning platform. But that's a deceptively simple description. Here's what's actually under the hood:
- A carrier integration layer that talks to multiple eSIM providers (GSMA-certified SM-DP+ servers) via the GSMA SGP.22 and SGP.32 specifications
- A plan catalog with real-time pricing, coverage maps, and availability across 190+ countries
- A provisioning API that generates and delivers eSIM profiles (QR codes, activation codes, SM-DP+ addresses) to end users in under 30 seconds
- A web dashboard and mobile app for browsing plans, purchasing, and managing active eSIMs
- Usage monitoring and top-up so travelers don't get stranded mid-trip
The platform handles the full lifecycle: browse → purchase → provision → activate → monitor → expire. All without a physical SIM card ever entering the picture. If you're new to the technology, here's a primer on what eSIM actually is and why it's replacing physical SIMs.
The Stack: What I Chose and Why
Go (Golang) for the Backend
I knew from day one this had to be Go. Here's the decision log:
Concurrency is not optional. An eSIM platform is fundamentally an I/O-bound system. Every purchase triggers a chain of operations: validate payment, check inventory with the carrier, request profile generation from the SM-DP+ server, wait for confirmation, deliver the profile to the user. If any of these block, the whole pipeline stalls. Go's goroutines make this trivial — each purchase is a lightweight goroutine, and the runtime handles scheduling across available threads. No thread pools. No async/await ceremony. Just go processPurchase(order) and move on.
Deployment simplicity. Traditional telecom stacks run on Java application servers (WebLogic, JBoss) that require gigabytes of RAM and dedicated ops teams. A Go binary is a single static file. I can deploy the entire RoamLink backend as a 15 MB binary that starts in milliseconds and runs happily on a $20/month VPS. For a bootstrapped startup, that matters.
Standard library that actually covers your needs. The net/http package is production-ready. crypto/tls handles the mutual TLS that GSMA specs require for SM-DP+ communication. encoding/json and encoding/xml cover the API formats. I didn't need a framework — just the standard library, a router (chi), and a database driver.
PostgreSQL — Not MongoDB, Not Cassandra
This was the most debated decision. The team had people arguing for MongoDB ("it's what startups use") and Cassandra ("we'll need the scale"). I pushed for PostgreSQL. Here's why:
eSIM data is relational. A user has many orders. An order has one eSIM profile. A profile belongs to one carrier plan. A plan covers many countries. This is a textbook relational model. Trying to denormalize this into documents or wide-column stores would create consistency nightmares.
Transactions matter when money is involved. When a user pays $30 for a 10GB plan, I need atomicity. Deduct from inventory, create the order, charge the card, request the profile — if any step fails, everything rolls back. PostgreSQL's ACID transactions make this straightforward. MongoDB's multi-document transactions exist but feel bolted-on.
JSONB gives us the best of both worlds. Carrier plan metadata varies wildly — some providers include throttling policies, others have fair-use clauses, some have time-of-day restrictions. PostgreSQL's JSONB columns let us store this semi-structured data alongside our relational schema without losing queryability. We can index into JSONB fields and join them with regular tables.
You probably don't have Cassandra-scale problems. RoamLink processes thousands of orders per day, not millions per second. PostgreSQL handles this on modest hardware. If we ever outgrow it, we'll have the revenue to hire a team that knows how to migrate. Premature scale optimization is the root of all over-engineering.
gRPC for Internal Services
The platform is split into microservices: catalog, orders, provisioning, billing, notifications. They need to talk to each other fast. I chose gRPC over REST for internal communication:
-
Protocol Buffers enforce a contract. Every service has a
.protofile that defines exactly what it accepts and returns. No more "is this field optional?" debates at 2 AM. The contract is the source of truth, and both client and server code are generated from it. - HTTP/2 multiplexing. The provisioning service makes parallel calls to multiple SM-DP+ servers. With REST, that's multiple TCP connections. With gRPC, it's multiplexed over a single HTTP/2 connection. Less overhead, fewer file descriptors, faster responses.
- Streaming for long-running operations. Profile generation on the carrier side can take 10-30 seconds. With gRPC server streaming, the provisioning service can send progress updates back to the orders service in real time, rather than polling.
The external API (for the web and mobile apps) is still REST — because browsers and mobile SDKs speak REST natively. But everything behind the firewall is gRPC.
Redis for the Hot Path
Plan pricing and availability change frequently. Querying the database on every page load is wasteful. Redis sits in front of PostgreSQL as a read-through cache:
- Plan catalog queries ("show me all plans for Japan") hit Redis first, PostgreSQL on cache miss
- Rate limiting for the provisioning API (carriers get unhappy if you hammer their SM-DP+ servers)
- Session management for the web dashboard
- Job queues for async operations like email delivery and usage data sync
Redis is one of those tools that starts as "just a cache" and quietly becomes the backbone of your infrastructure. I'm not mad about it.
When your infrastructure actually stays up through a traffic spike
Why Not the Traditional Telecom Stack?
The traditional way to build a platform like this would be:
- Java EE on WebLogic or JBoss — because "that's what telecom runs on"
- Oracle Database — because "it's enterprise-grade"
- SOAP/XML APIs — because GSMA specs are XML-heavy and "it's what carriers expect"
- Physical SIM logistics — warehouses, shipping, inventory management for plastic cards
Here's why I rejected every single one of those:
Java EE is a resource hog. A basic WebLogic instance needs 2-4 GB of RAM before you've written a single line of business logic. Multiply that by dev, staging, production, and you're looking at serious infrastructure costs before you have a single customer. Go gives us the same reliability with 50 MB of RAM per service.
Oracle licensing is a trap. I've been burned before. You start with the free tier, then you need partitioning, then you need RAC, and suddenly your database costs more than the rest of your infrastructure combined. PostgreSQL is free, forever, and the performance difference is negligible for our workload.
SOAP is dead, and XML is a crime scene. Yes, GSMA specs use XML. Yes, we have to parse it when talking to SM-DP+ servers. But that doesn't mean our entire API surface needs to be SOAP. We parse XML at the integration boundary, translate to Protobuf internally, and expose clean REST/JSON to our own clients. The carrier-facing code is the only place XML lives — and it's isolated behind a well-defined interface.
Physical SIMs are the problem, not the solution. The entire point of this platform is to eliminate physical SIM cards. Building a traditional SIM logistics operation — warehousing, shipping, inventory tracking — would be building the very thing we're trying to replace. eSIM provisioning is purely digital. No plastic. No shipping. No "sorry, we ran out of Japan SIMs."
The Hard Parts Nobody Talks About
Carrier Integration Is a Nightmare
Every eSIM carrier has their own API. Some use REST. Some use SOAP. Some use a custom binary protocol over TCP that was clearly designed in 2003 by one engineer who has since left the company. The GSMA specs define the profile format but not the ordering API. So you end up writing a new adapter for every carrier.
Our solution: an adapter pattern with a shared interface. Each carrier gets its own Go package that implements the Provisioner interface. The core provisioning service doesn't know or care which carrier it's talking to — it just calls provisioner.RequestProfile(ctx, req) and the adapter handles the rest. Adding a new carrier means writing one new package, not touching the core logic.
Time Zones Will Break Your Brain
An eSIM plan that's "valid for 7 days" — when does it expire? In the user's home timezone? The destination timezone? UTC? What if the user crosses the International Date Line mid-trip?
We settled on: all plan durations are in UTC, displayed to the user in their device's local timezone, with a clear countdown timer that shows "3 days 4 hours remaining" rather than an absolute date. It's not perfect, but it's the least confusing option we tested.
Payment Fragmentation
Travelers come from everywhere and pay with everything. Credit cards, Apple Pay, Google Pay, Alipay, Pix, UPI. Supporting all of these is a full-time job. We use Stripe as the primary processor with regional fallbacks, but the payment integration layer is easily 30% of the codebase.
What I'd Do Differently
- Start with observability. I shipped without proper tracing and regretted it within 48 hours. When a profile provisioning fails, you need to know exactly where — was it the payment? The carrier API? The SM-DP+ server? The notification delivery? OpenTelemetry from day one would have saved me a weekend of debugging.
- Don't build your own auth. I wrote a custom JWT-based auth system because "how hard can it be?" The answer: harder than you think, especially when you add refresh tokens, device management, and multi-factor auth. Use Clerk, Auth0, or Firebase Auth. Pay the money. Move on.
- Rate limit everything from the start. Carriers have aggressive rate limits, and they will cut you off without warning. We learned this the hard way when a pricing update job accidentally hammered a carrier's API and got our IP blocked for 4 hours. Implement token-bucket rate limiting on every outbound connection from day one.
- Write the carrier simulator first. Testing against real SM-DP+ servers is slow and expensive (some carriers charge per profile, even in test mode). A simulator that mimics the GSMA profile generation flow would have accelerated development dramatically. We built one eventually, but it should have been the first thing we wrote.
Looking back at the stack decisions — no regrets
The Numbers (So Far)
- 190+ countries covered through 12 carrier partnerships
- Average provisioning time: 18 seconds from payment to QR code delivery
- Infrastructure cost: ~$400/month on Hetzner and AWS (3 VPS instances, managed PostgreSQL, Redis)
- Lines of Go: ~28,000 across 6 microservices
- Lines of carrier adapter code: ~8,000 (for 12 carriers — that's ~650 lines per carrier on average)
- Uptime: 99.93% over the last 90 days
Is This Stack Right for You?
If you're building something similar — a platform that needs to talk to external APIs, handle payments, manage state, and scale from zero to thousands of users — Go + PostgreSQL + Redis + gRPC is a fantastic foundation. It's boring technology in the best way: well-understood, well-documented, and unlikely to surprise you at 3 AM.
If you're building a traditional telecom OSS/BSS with hundreds of existing SOAP integrations and a team of Java developers who've been doing this for 20 years — stick with Java. The best stack is the one your team can operate.
But if you're starting fresh, building something that didn't exist before, and you want to move fast without accumulating technical debt that'll crush you in year two — Go is the answer. It was for me. Check out iWanteSIM to see the platform in action, or read our step-by-step eSIM activation guide to see how the provisioning flow works from the user's perspective.
This is part of my "building in public" series. I'm documenting the entire journey of building RoamLink from idea to revenue. Follow along if you're into this kind of thing. And if you're building something in the connectivity space, I'd love to hear about your stack choices — drop a comment below.
Top comments (0)