Skip to content

System Design Fundamentals Every Developer Should Know

The core concepts behind scalable system design — load balancing, caching, database scaling, queues, and the tradeoffs that guide architectural decisions.

3 min read
Diagram of microservices passing through a load balancer to a bank of servers, which fan out to a CDN and database for read scaling and to a message queue.

Why Every Developer Needs System Design Knowledge

You don't need to be an architect to benefit from system design thinking. Understanding how the pieces fit together makes you a better developer at every level — it changes how you write code, how you debug production issues, and how you evaluate tradeoffs in daily decisions.

Concept 1: Horizontal vs Vertical Scaling

Vertical scaling — make the machine bigger (more CPU, RAM). Simple, but has a ceiling and a single point of failure.

Horizontal scaling — add more machines. Requires your application to be stateless.

Vertical:        Horizontal:
    [Big Server]     [Server] [Server] [Server]
         |               |       |       |
      [DB]           [Load Balancer]
                           |
                         [DB]

The key insight: stateless applications (where no user data is stored in application memory) can scale horizontally. If your server stores session data in memory, you can't route the same user to different servers.

Solution: externalize state.

tstypescript
// ❌ In-memory session — breaks horizontal scaling
const sessions = new Map<string, Session>();
app.use((req, res, next) => {
  req.session = sessions.get(req.cookies.sessionId);
  next();
});
 
// ✅ Externalized session — any server can serve any user
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
 
app.use(
  session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET,
  }),
);

Concept 2: Caching Layers

Caching is the most impactful performance optimization available at the system level. There are four places to cache:

Browser Cache → CDN → Application Cache (Redis) → Database Query Cache
     ↑              ↑              ↑                        ↑
  Static assets  Static + API   Computed results      Query results
  ms access      ~10ms           ~1ms                  ~5ms

The hardest part isn't implementing a cache — it's cache invalidation.

tstypescript
// Cache-aside pattern — the most common approach
async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;
 
  // 1. Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  // 2. Cache miss — fetch from database
  const product = await db.product.findUnique({ where: { id } });
  if (!product) throw new NotFoundError();
 
  // 3. Populate cache with TTL
  await redis.setEx(cacheKey, 3600, JSON.stringify(product)); // 1 hour TTL
 
  return product;
}
 
// Invalidate on mutation
async function updateProduct(id: string, data: Partial<Product>) {
  const product = await db.product.update({ where: { id }, data });
  await redis.del(`product:${id}`); // Invalidate cache entry
  return product;
}

Cache stampede: when the cache expires, all requests miss simultaneously and hammer the database. Mitigate with probabilistic early expiration or distributed locks.

Concept 3: Message Queues

Queues decouple producers from consumers, enabling:

  • Async processing — return a response immediately, process in background
  • Load leveling — absorb traffic spikes without overwhelming downstream services
  • Retry logic — failed jobs can be retried automatically
tstypescript
// Without a queue — user waits for email to send
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await sendWelcomeEmail(user.email); // Slow — blocks response
  res.json({ user });
});
 
// With a queue — response is immediate, email is async
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await queue.add("send-welcome-email", { userId: user.id }); // Fast — non-blocking
  res.json({ user });
});
 
// Worker processes jobs independently
queue.process("send-welcome-email", async (job) => {
  const user = await db.user.findUnique({ where: { id: job.data.userId } });
  await emailService.sendWelcome(user);
});

Concept 4: Database Scaling

When a single database can't handle the load:

Read Replicas — replicate writes to read-only replicas. Route read-heavy queries to replicas.

tstypescript
// Primary for writes, replica for reads
const writeDb = new PrismaClient({ datasources: { db: { url: PRIMARY_URL } } });
const readDb = new PrismaClient({ datasources: { db: { url: REPLICA_URL } } });
 
async function getUserDashboard(userId: string) {
  // Read from replica — ok if slightly stale
  return readDb.user.findUnique({
    where: { id: userId },
    include: { recentOrders: { take: 10 } },
  });
}
 
async function updateUserProfile(userId: string, data: Partial<User>) {
  // Write to primary — must be consistent
  return writeDb.user.update({ where: { id: userId }, data });
}

Sharding — partition data across multiple databases by a shard key (e.g., user ID ranges). Adds significant operational complexity — use read replicas first.

Concept 5: The CAP Theorem in Practice

In a distributed system, you can only guarantee two of three:

  • Consistency — every read sees the most recent write
  • Availability — every request receives a response
  • Partition tolerance — the system works despite network failures

Network failures happen. You must choose between Consistency and Availability during partitions.

Most web applications should choose Availability + Eventual Consistency:

tstypescript
// Eventual consistency — show cached data, sync in background
async function getInventoryCount(productId: string) {
  // Return cached count — may be slightly stale
  const cached = await redis.get(`inventory:${productId}`);
  if (cached) return parseInt(cached);
 
  // Fallback to database
  const product = await db.product.findUnique({
    where: { id: productId },
    select: { inventoryCount: true },
  });
  await redis.setEx(
    `inventory:${productId}`,
    30,
    String(product.inventoryCount),
  );
  return product.inventoryCount;
}

Banking transactions and medical records need strict consistency. Product inventory counts can tolerate a 30-second lag.

The Mental Model

System design is about understanding where the bottlenecks are and which tradeoffs are acceptable for your use case.

Questions to ask for every system:

  1. What is the read/write ratio?
  2. What is the acceptable latency?
  3. What happens when any component fails?
  4. What does the data access pattern look like?
  5. What are the consistency requirements?

Start simple. Add complexity only when you have evidence that the simpler solution is a bottleneck. Premature optimization in system design — over-engineering for scale you don't have yet — is as dangerous as writing premature micro-optimizations in code.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX