DEV Community

정진경
정진경

Posted on

I spent 6 months building my own SMS platform from scratch — here's what I learned

Hey DEV community! 👋
I want to share my journey building a global SMS platform and the technical challenges I faced along the way.
Why I built it
Like many developers, I needed SMS verification codes for my product. The options were painful:

Twilio: Reliable but expensive, monthly bills hurt
Local providers: Cheap but don't support international numbers
Middlemen: One time a campaign SMS was delayed 20 minutes, users complained like crazy

So I decided to build my own. 6 months later, here we are.

Tech Stack

Backend: Node.js + Express + Prisma
Database: SQLite (with WAL mode for concurrency)
Frontend: React 18 + Vite + Tailwind CSS
Infrastructure: Cloudflare Tunnel (no server needed!)

Challenge 1: SQLite Concurrency Locks
High concurrency kept throwing SQLITE_BUSY: database is locked.
Solution — three steps:
javascript// Step 1: Enable WAL mode
await prisma.$executeRawPRAGMA journal_mode=WAL;
await prisma.$executeRawPRAGMA synchronous=NORMAL;
await prisma.$executeRawPRAGMA busy_timeout=5000;

// Step 2: Single global Prisma instance
if (!global.prisma) {
global.
prisma = new PrismaClient();
}
export default global.__prisma;

// Step 3: Serialize write operations
class SerialQueue {
constructor() {
this.queue = [];
this.running = false;
}

async add(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.run();
});
}

async run() {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const { fn, resolve, reject } = this.queue.shift();
try { resolve(await fn()); }
catch (err) { reject(err); }
}
this.running = false;
}
}

Challenge 2: Floating Point Precision
SMS billing involves lots of decimal calculations. Users started seeing balances like -0.0000001.
Solution — store everything as integers:
javascript// Store as integer (multiply by 10000)
const toStorage = (amount) => Math.round(amount * 10000);

// Read back as decimal
const fromStorage = (stored) => stored / 10000;

// All calculations use integers
const deduct = (balance, price) => {
return fromStorage(toStorage(balance) - toStorage(price));
};

Challenge 3: 1200+ Carrier Response Formats
Every carrier returns different formats — some XML, some JSON, different field names, different success codes.
Solution — Adapter Pattern:
javascriptclass BaseAdapter {
async send(phone, content) {
throw new Error('Not implemented');
}

normalize(response) {
// Each carrier implements this
return {
success: false,
messageId: null,
errorCode: null
};
}
}

class CarrierAdapter extends BaseAdapter {
normalize(response) {
return {
success: response.status === 'success',
messageId: response.msgid,
errorCode: response.error || null
};
}
}
Adding a new carrier only requires writing one adapter — core logic stays untouched.

Challenge 4: Smart Routing
Multiple carrier channels running simultaneously. Built a real-time scoring system based on success rate, latency, and cost. Messages automatically route to the best channel, with instant failover if one goes down.
javascriptclass SMSRouter {
selectBestAdapter() {
return this.adapters.reduce((best, current) => {
return current.score > best.score ? current : best;
});
}

async send(phone, content) {
const adapter = this.selectBestAdapter();
return adapter.send(phone, content);
}
}

Results
The platform now supports:

🌍 190+ countries worldwide
📡 1,200+ carrier connections
⚡ Average 5-second delivery for domestic SMS
💰 60% cheaper than Twilio for equivalent usage

What's next

Adding more payment methods
Building a blog with SMS integration tutorials
Improving webhook reliability

If you're building something that needs SMS functionality, feel free to check it out at pulsemsg.vip — there's a free testing feature built in, no credit card required.
Would love to hear if anyone else has built something similar or has tips for scaling SMS infrastructure! 🚀

Tags: node javascript webdev showdev

Top comments (0)