Caching Strategies for High-Traffic Applications

·19 min read·Backend Developmentintermediate

Why in-memory acceleration matters more than ever as data volumes and latency budgets shrink

A high-level diagram showing an application server querying a fast in-memory cache before falling back to a slower database or external service

The first time I felt the pain of a slow database query, it wasn’t during a marketing spike or a holiday sale. It was a Tuesday morning, and a single analytics dashboard page was timing out because a complex JOIN was running for every user request. The team added an index, which helped a bit, but the root problem was that we kept asking the same question over and over. Caching changed that conversation. It turned repetitive work into a quick lookup, and more importantly, it taught us to design systems around what changes rarely versus what changes often.

In high‑traffic applications, caching is not a nice-to-have; it’s a design necessity. The data you read often changes far less frequently than the data you write, and users expect responses in milliseconds, not seconds. Caching reduces database load, smooths traffic spikes, and gives you headroom to scale without rewriting your data model. It can also hide partial failures when upstream services are slow, at the cost of increased complexity around invalidation. This article explores practical caching strategies, grounded in real usage, with code examples you can adapt. We’ll look at patterns beyond the basics, discuss tradeoffs, and share ways to avoid common traps.

Where caching fits in modern systems

Caching sits between your application and your slower data sources, whether that’s a relational database, a microservice, or a third-party API. The goal is simple: serve repeated reads from a fast store, and avoid work that can be shared across requests. In practice, that means choosing the right place to cache, the right eviction policy, and the right consistency model.

You’ll see caching in:

  • Web applications using per-request memoization or HTTP caching.
  • APIs and microservices that cache responses from other services.
  • Data pipelines that reuse transformed datasets during a window of time.
  • Event-driven systems where derived views are expensive to recompute.

Common languages used for caching logic include JavaScript/TypeScript for Node.js services, Python for data apps and backends, and Java or Go for performance-sensitive services. The core ideas are language-agnostic, but your choice of stack affects the tools you’ll use. Node.js services often rely on Redis or Memcached for distributed caching. Python services might use functools.lru_cache for in-process memoization and Redis for shared state. In Kubernetes environments, you might sidecar a cache or run a managed service like AWS ElastiCache or GCP Memorystore.

Compared to alternatives:

  • Adding replicas or sharding improves read throughput but does not reduce duplicate work.
  • Optimizing queries or adding indexes helps, but caching can provide orders-of-magnitude improvements for repeated reads without touching storage.
  • Streaming or precomputing materialized views is powerful but shifts complexity to the write path; caching can complement this by covering hot paths and short-term spikes.

The choice is not either-or; most high‑traffic systems use a combination: a database with indexes, a materialized view for heavy aggregates, and a caching layer to absorb bursts.

Core caching strategies and patterns

Good caching is about more than adding Redis. It’s about deciding where to store data, when to refresh it, and how to keep it consistent.

Client-side, CDN, and HTTP caching

Before data reaches your app, you can cache at the browser and edge:

  • HTTP caching headers (Cache-Control, ETag, Age) allow browsers and CDNs to cache static assets and even some API responses.
  • Reverse proxies like Nginx or Varnish can cache entire responses for a route.

Example Nginx configuration that caches API responses:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m inactive=60m use_temp_path=off;

server {
    listen 80;
    server_name api.example.com;

    location /v1/users {
        proxy_pass http://app_upstream;
        proxy_cache api_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri$is_args$args";
        proxy_cache_valid 200 302 10m;
        proxy_cache_valid 404 1m;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

For web apps, set sensible cache headers for static assets:

# Typical headers for static assets
Cache-Control: public, max-age=31536000, immutable

# For API responses that change occasionally
Cache-Control: public, max-age=60, stale-while-revalidate=30

Benefits: Reduces load on your app, lowers latency globally. Downsides: Stale data risk, harder to purge reliably. Use CDN caching for content that changes predictably, and include versioned filenames or purge hooks in your deployment pipeline.

In-process memoization

In-process caches keep data in the application memory. They’re the fastest option but limited to a single process. They shine for repeated computations, like rendered fragments or parsed configs.

Python example with functools.lru_cache:

from functools import lru_cache
import time

@lru_cache(maxsize=1024)
def compute_user_score(user_id: int) -> float:
    # Simulate an expensive computation or DB call
    time.sleep(0.1)
    return 42.0 + (user_id % 100) / 10.0

# First call computes, subsequent calls for same user_id hit the cache
print(compute_user_score(123))
print(compute_user_score(123))

Node.js example with a TTL cache using lru-cache:

const { LRUCache } = require("lru-cache");

const cache = new LRUCache({
  max: 1000,        // max number of entries
  ttl: 60_000,      // 60 seconds
});

async function getProduct(id) {
  const cached = cache.get(id);
  if (cached) return cached;

  const product = await db.query("SELECT * FROM products WHERE id = $1", [id]);
  cache.set(id, product);
  return product;
}

In-process caches are great for:

  • Per-request memoization to avoid repeated calls within the same request.
  • Feature flags or configs that update on a schedule.
  • Hot keys during traffic bursts, if your workload fits in memory.

Tradeoffs: No sharing across instances, memory pressure, and risk of stale data. In microservices, you often pair in-process caches with a distributed cache for shared state.

Distributed caching with Redis or Memcached

Distributed caches let multiple app instances share the same cache. Redis is versatile (strings, hashes, sets, sorted sets, streams), while Memcached is simpler and very fast for basic key-value storage.

A typical Redis use case is caching database rows keyed by ID, with a short TTL:

import redis
import json
import psycopg2
from psycopg2.extras import RealDictCursor

r = redis.Redis(host="redis", port=6379, db=0, decode_responses=True)

def get_user(user_id: int) -> dict:
    key = f"user:{user_id}"
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    conn = psycopg2.connect("dbname=app user=postgres")
    cur = conn.cursor(cursor_factory=RealDictCursor)
    cur.execute("SELECT id, email, name FROM users WHERE id = %s", (user_id,))
    user = cur.fetchone()
    cur.close()
    conn.close()

    if user:
        # Cache for 30 seconds
        r.setex(key, 30, json.dumps(user))
    return user or {}

Node.js example using ioredis with pipeline for multi-key reads:

const Redis = require("ioredis");
const redis = new Redis({ host: "redis", port: 6379 });

async function getUsers(userIds) {
  const pipeline = redis.pipeline();
  userIds.forEach(id => pipeline.get(`user:${id}`));
  const results = await pipeline.exec();

  const missed = [];
  const users = results.map(([err, data], idx) => {
    if (!data) {
      missed.push(userIds[idx]);
      return null;
    }
    return JSON.parse(data);
  });

  if (missed.length) {
    // Bulk fetch from DB to avoid N+1
    const placeholders = missed.map((_, i) => `$${i + 1}`).join(",");
    const dbRows = await dbQuery(`SELECT id, email, name FROM users WHERE id IN (${placeholders})`, missed);
    // Write to cache in a single pipeline
    const writePipeline = redis.pipeline();
    dbRows.forEach(row => {
      writePipeline.setex(`user:${row.id}`, 30, JSON.stringify(row));
    });
    await writePipeline.exec();

    // Merge results
    const map = new Map(dbRows.map(r => [r.id, r]));
    for (let i = 0; i < users.length; i++) {
      if (users[i] === null) users[i] = map.get(userIds[i]) || null;
    }
  }

  return users;
}

Memcached is often used for simple key-value caching where advanced data structures aren’t required. It’s widely supported, very fast, and has simple TTL semantics. However, Redis has become the default for teams that need richer structures or pub/sub for invalidation.

Cache-aside vs read-through vs write-through

  • Cache-aside (lazy loading): The application checks the cache first; on a miss, it loads from the database and updates the cache. This is the most common pattern and gives you fine-grained control. Example: the Python get_user function above.
  • Read-through: A cache library or service handles loading data on misses. For example, a Redis-backed client that knows how to populate itself. This simplifies app code but requires a compatible client or cache service.
  • Write-through: Writes update the cache and the database in a single transaction or atomic flow. This keeps the cache consistent but increases write latency and complexity. Use for read-heavy workloads where stale writes are unacceptable.

Example write-through in Node.js for a user profile update:

async function updateUserProfile(userId, payload) {
  await dbQuery(
    "UPDATE users SET name = $1, email = $2 WHERE id = $3",
    [payload.name, payload.email, userId]
  );
  const fresh = await dbQuery("SELECT id, email, name FROM users WHERE id = $1", [userId]);
  await redis.setex(`user:${userId}`, 30, JSON.stringify(fresh[0]));
}

Write-back and refresh-ahead

Write-back: Writes go to the cache first and are flushed to the database asynchronously. This improves write latency but risks data loss if the cache fails. Suitable for non-critical data or metrics.

Refresh-ahead: Proactively refresh cache entries before they expire, based on access patterns. This reduces latency spikes when popular keys expire. Use a background job or scheduler to warm the cache:

# A simple refresh-ahead using a background scheduler
from apscheduler.schedulers.background import BackgroundScheduler

def warm_top_keys():
    top_keys = r.zrange("access:leaderboard", 0, 99)  # Sorted set of hot keys
    for key in top_keys:
        # Recompute and set with TTL
        value = compute_for_key(key)
        r.setex(key, 30, json.dumps(value))

scheduler = BackgroundScheduler()
scheduler.add_job(warm_top_keys, "interval", seconds=15)
scheduler.start()

Cache invalidation and consistency

The hardest part of caching is invalidation. Some proven approaches:

  • TTLs: Simple, but risk stale reads. Use short TTLs for fast-changing data and longer ones for slowly changing configs.
  • Versioned keys: Embed a data version in the key (e.g., user:123:v3). When the data changes, the key changes; old entries expire naturally. This avoids explicit deletion.
  • Event-driven invalidation: Publish invalidation events (via Redis pub/sub, Kafka, or your internal message bus) to remove stale entries across caches.

Example event-driven invalidation in Python with Redis pub/sub:

def invalidate_user(user_id):
    r.publish("invalidate", f"user:{user_id}")

def subscriber():
    pubsub = r.pubsub()
    pubsub.subscribe("invalidate")
    for message in pubsub.listen():
        if message["type"] == "message":
            key = message["data"].decode("utf-8")
            r.delete(key)

Consistency models:

  • Strong consistency: Use write-through or lock-based updates. Adds latency.
  • Eventual consistency: TTLs plus invalidation events. Good for most user-facing features.
  • Read-your-writes: Include a request-scoped token (e.g., last_update_ts) in cache keys for a short window after writes.

Sharding and routing

At scale, a single Redis instance can become a bottleneck. Sharding distributes keys across multiple Redis nodes. Tools like Redis Cluster or proxy-based sharding (e.g., Twemproxy) help route requests. Route related keys to the same shard to avoid cross-shard operations. For example, use consistent hashing with hash(user_id) to choose a shard for all user:{id} entries.

Multi-level caching

Most high‑traffic systems use multiple layers:

  • L1: In-process cache for microsecond reads, short TTL.
  • L2: Distributed cache (Redis) for shared data, medium TTL.
  • L3: Database or materialized views for the source of truth.

Example flow for an API endpoint:

def get_product_details(product_id: int):
    # L1: per-request memoization
    if "l1_cache" not in g:
        g.l1_cache = {}
    cache_key = f"product:{product_id}"
    if cache_key in g.l1_cache:
        return g.l1_cache[cache_key]

    # L2: Redis
    data = r.get(cache_key)
    if data:
        parsed = json.loads(data)
        g.l1_cache[cache_key] = parsed
        return parsed

    # L3: Database
    product = db_query_one("SELECT * FROM products WHERE id = %s", (product_id,))
    if product:
        r.setex(cache_key, 60, json.dumps(product))
        g.l1_cache[cache_key] = product
    return product

Caching derived data with sorted sets

Redis sorted sets are excellent for leaderboards or “most viewed” lists that update frequently:

def record_view(product_id: int):
    r.zincrby("product:views", 1, str(product_id))

def top_products(n: int = 10):
    ids = r.zrevrange("product:views", 0, n - 1)
    # Fetch details in a pipeline
    pipeline = r.pipeline()
    for pid in ids:
        pipeline.get(f"product:{pid}")
    results = pipeline.exec()
    return [json.loads(r) for _, r in results if r]

Idempotency and request coalescing

Duplicate requests can overwhelm downstream services. Use request coalescing: when multiple requests ask for the same key, only let one populate the cache; others wait for the result.

Node.js example with a per-key promise map:

const pending = new Map();

async function getProduct(id) {
  if (cache.has(id)) return cache.get(id);
  if (pending.has(id)) return pending.get(id);

  const promise = dbQuery("SELECT * FROM products WHERE id = $1", [id])
    .then(row => {
      cache.set(id, row);
      pending.delete(id);
      return row;
    })
    .catch(err => {
      pending.delete(id);
      throw err;
    });

  pending.set(id, promise);
  return promise;
}

Observability

Caching should be measurable:

  • Track hit/miss ratio by route and key prefix.
  • Monitor latency percentiles with and without cache.
  • Alert on sudden drops in hit rate, which may indicate misconfigured TTLs or invalidation storms.
  • Add cache status headers (X-Cache-Hit: true, X-Cache-Region: redis) to help debugging.

In Node.js with Prometheus:

const client = require("prom-client");
const cacheHits = new client.Counter({ name: "cache_hits_total", labelNames: ["region"] });
const cacheMisses = new client.Counter({ name: "cache_misses_total", labelNames: ["region"] });

async function getProduct(id) {
  const cached = await redis.get(`product:${id}`);
  if (cached) {
    cacheHits.inc({ region: "redis" });
    return JSON.parse(cached);
  }
  cacheMisses.inc({ region: "redis" });
  const row = await dbQuery("SELECT * FROM products WHERE id = $1", [id]);
  await redis.setex(`product:${id}`, 60, JSON.stringify(row));
  return row;
}

Real-world code context

The following example brings together several patterns in a Node.js/Express API that caches user profiles and invalidates them on updates. It uses an in-process LRU for per-request caching and Redis for shared caching, with event-driven invalidation.

Project structure:

services/user-api
├── src
│   ├── index.js
│   ├── routes.js
│   ├── cache.js
│   ├── db.js
│   └── invalidation.js
├── Dockerfile
├── package.json
└── nginx.conf

package.json (relevant deps):

{
  "name": "user-api",
  "version": "1.0.0",
  "main": "src/index.js",
  "dependencies": {
    "express": "^4.18.2",
    "ioredis": "^5.3.1",
    "lru-cache": "^10.0.0",
    "prom-client": "^14.2.0",
    "pg": "^8.11.0"
  },
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}

src/cache.js: shared Redis client plus per-request L1 cache:

const Redis = require("ioredis");
const { LRUCache } = require("lru-cache");

const redis = new Redis({ host: process.env.REDIS_HOST || "127.0.0.1", port: 6379 });

const l1Cache = () => new LRUCache({
  max: 500,
  ttl: 5_000, // 5 seconds
});

module.exports = { redis, l1Cache };

src/db.js: minimal Postgres client helpers:

const { Pool } = require("pg");

const pool = new Pool({
  host: process.env.PGHOST || "localhost",
  user: process.env.PGUSER || "postgres",
  password: process.env.PGPASSWORD || "postgres",
  database: process.env.PGDATABASE || "app",
});

async function queryOne(text, params) {
  const res = await pool.query(text, params);
  return res.rows[0];
}

async function query(text, params) {
  const res = await pool.query(text, params);
  return res.rows;
}

module.exports = { queryOne, query };

src/invalidation.js: Redis pub/sub subscriber for invalidation events:

const { redis } = require("./cache");
const EventEmitter = require("events");

const bus = new EventEmitter();

function startSubscriber() {
  const pubsub = redis.duplicate();
  pubsub.subscribe("invalidate:user", (err, count) => {
    if (err) console.error("Failed to subscribe", err);
    else console.log(`Subscribed to ${count} channels`);
  });

  pubsub.on("message", (channel, message) => {
    if (channel === "invalidate:user") {
      const userId = message;
      redis.del(`user:${userId}`);
      bus.emit(`invalidated:user:${userId}`);
    }
  });
}

function invalidateUser(userId) {
  redis.publish("invalidate:user", userId);
}

module.exports = { startSubscriber, invalidateUser, bus };

src/routes.js: endpoints using L1 and L2 caches, plus invalidation:

const express = require("express");
const { redis, l1Cache } = require("./cache");
const { queryOne } = require("./db");
const { invalidateUser } = require("./invalidation");

const router = express.Router();

router.get("/users/:id", async (req, res) => {
  const userId = req.params.id;
  const l1 = res.locals.l1 || (res.locals.l1 = l1Cache());

  const l1Key = `user:${userId}`;
  if (l1.has(l1Key)) {
    return res.json(l1.get(l1Key));
  }

  const cached = await redis.get(l1Key);
  if (cached) {
    const data = JSON.parse(cached);
    l1.set(l1Key, data);
    res.set("X-Cache-Status", "hit-redis");
    return res.json(data);
  }

  const user = await queryOne("SELECT id, email, name FROM users WHERE id = $1", [userId]);
  if (!user) return res.status(404).json({ error: "not found" });

  await redis.setex(l1Key, 30, JSON.stringify(user));
  l1.set(l1Key, user);
  res.set("X-Cache-Status", "miss");
  res.json(user);
});

router.put("/users/:id", async (req, res) => {
  const userId = req.params.id;
  const { name, email } = req.body;

  await queryOne(
    "UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email",
    [name, email, userId]
  );

  // Write-through: update Redis immediately
  const updated = { id: parseInt(userId, 10), name, email };
  await redis.setex(`user:${userId}`, 30, JSON.stringify(updated));

  // Also publish invalidation so other caches can refresh if needed
  invalidateUser(userId);

  res.json(updated);
});

module.exports = router;

src/index.js: bootstrap Express with metrics and subscriber:

const express = require("express");
const { register } = require("prom-client");
const routes = require("./routes");
const { startSubscriber } = require("./invalidation");

const app = express();
app.use(express.json());
app.use("/metrics", async (_, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});
app.use("/", routes);

startSubscriber();

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`User API listening on ${port}`);
});

nginx.conf: reverse proxy caching for static assets and API:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m inactive=10m;

server {
    listen 80;
    server_name api.example.com;

    location /metrics {
        # Metrics are dynamic, don’t cache
        proxy_pass http://localhost:3000;
        proxy_cache off;
    }

    location / {
        proxy_pass http://localhost:3000;
        proxy_cache api_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        proxy_cache_valid 200 302 5m;
        proxy_cache_valid 404 1m;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

Dockerfile for local dev:

FROM node:20-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]

This setup shows a realistic path: start with Redis as the shared cache, add per-request L1 for microsecond reads, use write-through on updates, and propagate invalidation via pub/sub. From here, you can evolve to multi-level caches, request coalescing, and shard Redis as traffic grows.

Honest evaluation: strengths, weaknesses, and tradeoffs

Caching can be transformative, but it introduces new failure modes.

Strengths:

  • Dramatic latency reduction for repeated reads.
  • Reduced database load and cost.
  • Smooths bursty traffic; protects upstream services.
  • Enables features like leaderboards and near-real-time aggregates.

Weaknesses:

  • Invalidation complexity: stale data, double writes, thundering herd on expiry.
  • Operational overhead: monitoring, capacity planning, failover.
  • Consistency tradeoffs: strong consistency increases latency.
  • Hidden costs: memory, network, serialization overhead.

When caching shines:

  • Read-heavy workloads with hot keys.
  • API responses with predictable TTLs.
  • Derived data that is expensive to recompute.
  • Microservices that call each other frequently.

When caching might not be a good choice:

  • Write-heavy workloads with constantly changing data.
  • Strongly consistent financial transactions where stale reads are unacceptable.
  • Very small datasets that fit in memory and are already fast.
  • Scenarios where invalidation complexity outweighs benefits.

Common tradeoffs:

  • TTL length vs. freshness: short TTLs increase misses and load; long TTLs risk stale data.
  • In-process vs. distributed: in-process is faster but fragmented; distributed is consistent but adds latency.
  • Client-side vs. server-side: CDNs are great for static assets but tricky for personalized data.

Personal experience: lessons from real systems

I’ve learned a lot by breaking caches. Once, we had a hot key for a feature flag that expired every 5 minutes. At scale, that expiration caused a stampede, with hundreds of requests hitting the DB at the same moment. The fix was twofold: extend TTL slightly and add a background refresh that warmed the cache before expiry. We also used a lock (Redis SETNX) to let one request recompute the value while others waited. That taught me the value of refresh-ahead and request coalescing.

Another mistake was mixing cache keys across tenants without namespacing. A purge intended for tenant A accidentally cleared tenant B’s data because both used keys like report:123. Since then, I always prefix keys with tenant and data version, e.g., tenant:acme:report:v2:123. It’s trivial but prevents entire classes of bugs.

Caching also changed how I write code. I now ask at the API boundary: “What data changes rarely, and what changes often?” That shapes the design. For example, user profiles change relatively infrequently compared to their activity streams. We cache profiles aggressively and stream activities without caching. It’s a simple heuristic, but it’s guided many good decisions.

Getting started: setup, tooling, and workflow

Start by defining your caching zones and budgets. Then build the observability you need to see hits and misses. Finally, add caching incrementally for the hottest endpoints.

Workflow:

  • Identify hot paths with metrics. If a single endpoint accounts for a large share of DB time, it’s a candidate.
  • Decide on scope: request-scoped (in-process), shared (Redis), or edge (CDN).
  • Choose TTL and invalidation strategy. Start conservative; shorten TTL if you see stale data.
  • Implement write-through on updates or event-driven invalidation for cross-service consistency.
  • Add dashboards for hit rate, latency, and error rates. Test invalidation in staging.
  • Plan for scale: shard Redis, introduce multi-level caches, and optimize serialization.

Tooling:

  • Redis or Memcached for distributed caching.
  • Nginx or Varnish for reverse proxy caching.
  • Prometheus + Grafana for metrics.
  • redis-cli for interactive exploration and debugging.
  • Libraries: ioredis (Node.js), redis-py (Python), Lettuce (Java), go-redis (Go).

Project structure for a small service:

my-service/
├─ src/
│  ├─ cache/           # Redis client, L1 cache helpers
│  ├─ db/              # Database clients
│  ├─ routes/          # API endpoints
│  ├─ workers/         # Background refreshers, invalidation subscribers
│  └─ metrics.js       # Prometheus metrics
├─ config/
│  ├─ nginx.conf
│  └─ redis.conf
├─ docker-compose.yml
├─ package.json
└─ README.md

A minimal docker-compose for local dev:

version: "3.8"
services:
  app:
    build: .
    environment:
      - REDIS_HOST=redis
      - PGHOST=postgres
      - PGUSER=postgres
      - PGPASSWORD=postgres
      - PGDATABASE=app
    ports:
      - "3000:3000"
    depends_on:
      - redis
      - postgres
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=app
    ports:
      - "5432:5432"

Free learning resources

Summary: who should use caching and who might skip it

Caching is a core strategy for high‑traffic applications that need low latency and high throughput. If your system is read-heavy, has hot keys, or calls expensive downstream services, caching will likely deliver significant gains. Teams building user-facing APIs, content platforms, and real-time dashboards benefit the most.

You might skip or defer caching if:

  • Your dataset is small and your DB is already fast enough.
  • Your workload is write-heavy with strong consistency requirements.
  • The complexity of invalidation is higher than the performance gains, such as in rapidly changing transactional systems.

Takeaway: start small, measure, and evolve. Cache the hottest reads first, keep TTLs tight until you prove safety, and invest in observability. Caching isn’t magic, but it is a powerful design tool. When used thoughtfully, it turns repetitive work into shared memory, and your application feels faster not because you added more hardware, but because you stopped asking the same question twice.