Between xThreads.app and PostQueue.app, I have taken two SaaS products from zero to thousands of users. Both went through the same painful, exhilarating process: the MVP that was barely holding together, the growth spurt that broke everything, and the re-architecture that made it stable.
xThreads is an AI-powered Twitter content tool. PostQueue is a multi-platform social media scheduler with AI translation and optimization. Different products, same scaling challenges. This post captures the patterns I wish I had known before starting - the architecture decisions that made the difference between firefighting and scaling smoothly.
What you will learn: Phase 1: MVP architecture that ships fast · Phase 2: Infrastructure optimization (caching, CI/CD, connection pooling) · Phase 3: Decomposition into services (user, AI, notification) · Phase 4: Multi-tenancy, database sharding, circuit breakers · The hard reality: what broke at each stage and how we fixed it · Practical code you can use today
1. Phase 1: The MVP - Ship Before It Is Perfect
Both xThreads and PostQueue started as monoliths. A single Next.js application, a single PostgreSQL database, a single deployment. The architecture was deliberately boring:
Diagram 1: MVP architecture (both products)
Why a monolith? Two reasons. First, speed matters more than scale at this stage. I could add a feature, deploy, and see results in minutes. Second,you do not know what will bottleneck yet. Prematurely splitting into services means guessing at boundaries you will reassess later.
What Worked
- One deploy command: git push → build → deploy in under 2 minutes.
- Simple database: A single PostgreSQL instance with connection pooling handled everything.
- No message queue: Background jobs ran as inline async tasks (Node.js event loop handled them fine at this scale).
2. Phase 2: Infrastructure Optimization
When user growth accelerated past ~1,000 users, the first bottlenecks appeared. Not code bottlenecks - infrastructure bottlenecks. The database started showing high CPU. Deployments required downtime. Session storage hit limits.
Database Optimization
The first and most impactful change was database optimization. Connection pooling was the single most effective fix:
import { createPool } from "@vercel/postgres";
// Before: New connection for every request (slow + connection exhaustion)
// After: Reuse connections via a pool
const pool = createPool({
connectionString: process.env.DATABASE_URL,
maxConnections: 20, // Never exceed 20 concurrent connections
idleTimeoutMs: 30000, // Close idle connections after 30s
maxUses: 1000, // Refresh connections every 1000 uses to prevent leaks
});
export async function query(text: string, params?: any[]) {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 100) {
console.warn(`Slow query (${duration}ms): ${text.substring(0, 100)}`);
}
return result;
}Redis for Session and Data Caching
For xThreads, caching AI-generated content was a game-changer. Users often viewed the same content multiple times - without caching, every view triggered a database query. With Redis, the same content was served from memory:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
// Cache generated content for 5 minutes
const CACHE_TTL = 300;
export async function getCachedOrGenerate(userId: string, generateFn: () => Promise<any>) {
const cacheKey = `content:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const fresh = await generateFn();
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(fresh));
return fresh;
}
// Cache hit rate: ~70% - 70% of reads served from memory, not the databaseDiagram 2: Phase 2 architecture - optimized monolith
CI/CD Automation
Before automation, deployments were manual and risky. After:
// .github/workflows/deploy.yml (simplified)
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm build
- run: pnpm test # If tests fail, deploy stops
- run: pnpm run migrate # Database migrations run automatically
- run: pnpm run deploy # Zero-downtime deploy via Docker/Railway3. Phase 3: Architectural Transformation
At several thousand users, the monolith started showing real cracks. Deployments became nerve-wracking because a change anywhere could break anything. Background AI generation was blocking API responses. The database was running hot despite replicas.
We decomposed the monolith into three services, one at a time:
Diagram 3: Post-decomposition architecture
First Split: The AI Service
The AI content generation was the heaviest workload. A single generation request to OpenAI could take 5–15 seconds. In the monolith, this blocked other API requests. Extracting it into a separate service solved two problems:
// AI Service - independently scaled, async communication
import { Queue } from "bullmq";
const aiQueue = new Queue("ai-generation", {
connection: { host: "localhost", port: 6379 },
});
// The frontend enqueues a generation job and returns immediately
export async function requestGeneration(userId: string, prompt: string) {
const job = await aiQueue.add("generate", { userId, prompt });
return { jobId: job.id, status: "queued" };
}
// The AI service processes jobs asynchronously
// (runs on its own server, scaled independently)
// Worker: ai-worker.ts
import { Worker } from "bullmq";
const worker = new Worker("ai-generation", async (job) => {
const { userId, prompt } = job.data;
const result = await callOpenAI(prompt);
await saveResult(userId, result);
return result;
}, { concurrency: 5 }); // Generate 5 posts in parallelThe AI service was the right first split because it had the clearest boundary: input (prompt + user ID) → output (generated content). No complex shared state with the rest of the application.
4. Phase 4: Multi-Tenancy and Sharding
At thousands of active users, multi-tenancy became critical. A noisy neighbor problem emerged: one user's heavy usage (generating hundreds of posts) slowed the database for everyone else.
Diagram 4: Multi-tenancy evolution
Database Sharding
// Shard router - hash user_id to pick the right database
const SHARDS = [
process.env.DATABASE_SHARD_1_URL,
process.env.DATABASE_SHARD_2_URL,
process.env.DATABASE_SHARD_3_URL,
];
function getShard(userId: string): string {
// Simple hash: sum of char codes modulo shard count
const hash = userId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return SHARDS[hash % SHARDS.length];
}
// Usage
async function getUserData(userId: string) {
const shardUrl = getShard(userId);
const pool = new Pool({ connectionString: shardUrl });
const user = await pool.query("SELECT * FROM users WHERE id = $1", [userId]);
return user.rows[0];
}
// Adding a shard: update SHARDS array, rebalance in background
// (reads from old + new shards during migration)5. Lessons from the Journey
These are the non-obvious lessons that cost me real pain to learn:
Lesson 1: The Database Is Always the First Bottleneck
I have never seen a SaaS application where the application code was the bottleneck before the database. Optimize your queries, add connection pooling, then add read replicas - in that order.
Lesson 2: Extract the Heaviest Service First
When decomposing a monolith, start with the service that has the clearest boundary and the heaviest resource usage. For xThreads and PostQueue, that was the AI generation service. The clearest boundaries make the safest first split.
Lesson 3: Multi-Tenancy Is a Product Problem Before a Technical One
The right isolation strategy depends on your pricing model, not your database. Do not shard until you have measured that a single database cannot handle your workload, and not a moment before.
Diagram 5: The scaling roadmap visualised
6. Summary: What to Remember
- Start with a boring monolith. Next.js + PostgreSQL is the fastest path to product-market fit. Do not complicate until you have users to complicate for.
- Connect your database properly. Connection pooling and query optimization will solve 80% of your performance problems before you need to split services.
- Add Redis caching before you add database replicas. A single Redis instance can absorb 70%+ of your read traffic.
- Decompose by extracting the heaviest, clearest-boundary service first. AI generation was the right first split for both xThreads and PostQueue.
- Each phase buys time for the next. Optimize buys time for decomposition. Decomposition buys time for sharding. Do not jump ahead - the complexity will outpace your understanding of the problem.