N
Noah Sheldon
BlogProjectsSkillsExperienceLinks
free ai audit
Home/Blog/High Level Design/Scaling SaaS Applications: PostQueue & xThreads Lessons
Deep DiveHigh Level DesignSaaSScaling

Scaling SaaS Applications: PostQueue & xThreads Lessons

Real-world lessons from scaling two SaaS products from MVP to thousands of users - architecture decisions, pitfalls, and wins.

Jul 19, 2026
13 min

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)

BrowserNext.js App (Monolith)UI · API · Auth · AIPostgreSQLSingle instanceFast to build. Easy to deploy. Perfect for 0–1000 users.

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 database

Diagram 2: Phase 2 architecture - optimized monolith

UsersCDN (static)App (scaled)PoolRedisPostgres+ replicasCI/CD pipelineAutomated deploys (zero-downtime)Key additions: connection pooling, Redis caching, CI/CD, read replicas

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/Railway

3. 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

API GatewayUser ServicePosts ServiceAI ServiceNotif ServiceMessage QueueUser DBPosts DBAI Cache DBNotif DB

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 parallel

The 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

Phase A: Shared DBAll users in one table - noisy neighbors!Phase B: ShardedShard 1Shard 2Shard 3Each shard is an independent Postgres instance. Shard key: user_id hash.

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

MVP0–1k usersMonolithOptimize1–5k usersPool + Redis + CI/CDDecompose5–20k users3 services + queueScale20k+ usersShard + Circuit breakersEach phase buys you time for the next.Skip phases and you pay in downtime.

6. Summary: What to Remember

Ship Monolith → Optimize Infra → Decompose → Shard
  • 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.

Chapter

High Level Design

Reading level

Deep Dive

About the author

NS

Noah Sheldon

Applied AI/ML @ Fitch

Keep reading

Low Level Design

Building xThreads: From Idea to Chrome Extension with 1000+ Users

The complete story behind creating xThreads.app - an AI-powered Twitter tool that gained traction in the social media space.

Read · 12 min

Engineering Leadership

6 Years of Software Development: My Journey from Data Science to Full Stack Founder

A comprehensive look at my evolution from a data science graduate to building SaaS products and working at major financial institutions.

Read · 8 min
NS

Noah Sheldon

Associate Director, Applied AI/ML @ Fitch Ratings

Get a free AI opportunity audit

never miss a deep dive

New essays land when they ship. No spam, unsubscribe anytime.