System Design ·

Cell-Based Architecture: Failure Isolation, Blast Radius Reduction, and Multi-Cell Routing at Scale

A practical guide to cell-based architecture: how to size cells, build routing layers, handle cross-cell data, deploy safely across cells, and decide whether the operational complexity is justified for your scale.

Cell-Based Architecture: Failure Isolation, Blast Radius Reduction, and Multi-Cell Routing at Scale

Multi-AZ deployments solve hardware failure. They do not solve application failure. When a bad deploy, a runaway query, or a tenant with pathological traffic takes down your service, it takes down all of your tenants simultaneously because all of them share the same application tier, the same database connection pool, and the same deployment target.

Cell-based architecture addresses this by partitioning the entire stack, not just the infrastructure, into independent units. Each cell is a complete, self-contained copy of your service capable of handling a defined slice of your tenant population. A failure in one cell is physically and logically isolated from every other cell.

This is the pattern that underlies the way Amazon AWS partitions its internal services, how Slack organizes its workspace infrastructure, and how Stripe handles its payment processing environments. The tradeoffs are real and the operational overhead is significant. This article covers what the pattern actually involves in practice.

The Problem Multi-AZ Alone Does Not Solve

A typical multi-AZ setup deploys the same application across three availability zones behind a load balancer. If an AZ goes down, traffic shifts to the remaining two. This handles infrastructure failures well.

What it does not handle:

  • A buggy deploy that crashes the app process on all nodes simultaneously
  • A single tenant running a query that exhausts the shared database connection pool
  • A thundering herd from one customer that causes CPU saturation across all nodes
  • An operator error (bad config change, wrong migration) that corrupts shared state

In all of these cases, 100% of your tenants are affected. Your blast radius is the entire service.

Cell-based architecture makes the blast radius bounded. If cell-03 goes down, tenants assigned to cell-03 are affected. Tenants on cell-01, cell-02, and cell-04 continue operating normally.

What a Cell Actually Contains

A cell is not just an isolated database. It is an isolated stack. At minimum, each cell includes:

  • Its own application tier (separate autoscaling group or Kubernetes cluster namespace)
  • Its own database (separate RDS instance or cluster, not a replica of a shared primary)
  • Its own cache layer (separate Redis or Memcached cluster)
  • Its own message queue or event stream partition if applicable
  • Its own observability stack or at minimum separate metric namespaces

The application code is identical across cells. The configuration, secrets, and connection strings are cell-specific. A cell has no runtime dependency on any other cell.

// Cell configuration resolved at startup from environment
interface CellConfig {
  cellId: string;           // e.g. "cell-03"
  region: string;           // e.g. "us-east-1"
  dbHost: string;           // cell-specific RDS endpoint
  cacheHost: string;        // cell-specific Redis endpoint
  queueArn: string;         // cell-specific SQS queue
  tenantCapacity: number;   // max tenants this cell will accept
}

function loadCellConfig(): CellConfig {
  return {
    cellId: requireEnv("CELL_ID"),
    region: requireEnv("AWS_REGION"),
    dbHost: requireEnv("CELL_DB_HOST"),
    cacheHost: requireEnv("CELL_CACHE_HOST"),
    queueArn: requireEnv("CELL_QUEUE_ARN"),
    tenantCapacity: parseInt(requireEnv("CELL_TENANT_CAPACITY"), 10),
  };
}

The cell ID is a first-class runtime concept. Every log line, every metric, every trace includes it.

Cell Sizing Strategies

Sizing is the hardest design decision. Too large and you have not reduced blast radius meaningfully. Too small and operational overhead balloons without proportional benefit.

Tenant-count sizing is the most common approach: define a maximum number of tenants per cell and spin up a new cell when you approach that limit. A reasonable starting point is 500-2000 tenants per cell depending on your per-tenant resource profile. This works well when tenants are roughly homogeneous.

Resource-based sizing is better when tenants vary significantly in usage. Instead of counting tenants, you track CPU, memory, and DB connection utilization per cell and split when any resource approaches a threshold (typically 60-70% to leave headroom for burst).

Tier-based sizing separates tenants by their service tier before sizing within each tier. Enterprise tenants often get dedicated cells. Free-tier tenants are packed more densely. This is both an isolation strategy and a sales strategy.

type TenantTier = "enterprise" | "pro" | "free";

interface TenantAssignment {
  tenantId: string;
  cellId: string;
  tier: TenantTier;
  assignedAt: Date;
}

// Assignment is immutable after initial provisioning.
// Migrations happen explicitly, not automatically.
async function assignTenantToCell(
  tenantId: string,
  tier: TenantTier,
  cellRegistry: CellRegistry
): Promise<TenantAssignment> {
  const eligibleCells = await cellRegistry.getCellsWithCapacity(tier);

  if (eligibleCells.length === 0) {
    throw new Error(`No capacity available for tier ${tier}. Provision a new cell.`);
  }

  // Prefer least-loaded cell to spread tenants evenly
  const targetCell = eligibleCells.sort(
    (a, b) => a.currentTenantCount - b.currentTenantCount
  )[0];

  const assignment: TenantAssignment = {
    tenantId,
    cellId: targetCell.cellId,
    tier,
    assignedAt: new Date(),
  };

  await cellRegistry.recordAssignment(assignment);
  return assignment;
}

One practical rule: never assign a tenant to a cell automatically during a live request path. Assignment happens at provisioning time. The routing layer reads a pre-built mapping. This removes assignment logic from the hot path entirely.

The Routing Layer

The routing layer sits in front of all cells. Its job is to inspect every inbound request, resolve which cell owns the requesting tenant, and proxy the request to that cell’s endpoint.

The mapping lives in a central store (DynamoDB or Postgres) but is cached aggressively at the router. Stale routing data for a migrated tenant means a few failed requests, not data corruption, so a TTL of 30-60 seconds is usually acceptable.

interface RoutingEntry {
  tenantId: string;
  cellEndpoint: string;  // https://cell-03.internal.example.com
  cellId: string;
  updatedAt: number;
}

class CellRouter {
  private cache: Map<string, RoutingEntry> = new Map();
  private readonly cacheTtlMs = 60_000;

  async route(req: Request): Promise<Response> {
    const tenantId = this.extractTenantId(req);

    if (!tenantId) {
      return new Response("Missing tenant identifier", { status: 400 });
    }

    const entry = await this.resolveWithCache(tenantId);

    if (!entry) {
      return new Response("Tenant not found", { status: 404 });
    }

    const targetUrl = new URL(req.url);
    targetUrl.hostname = new URL(entry.cellEndpoint).hostname;

    const proxied = new Request(targetUrl.toString(), {
      method: req.method,
      headers: req.headers,
      body: req.body,
    });

    proxied.headers.set("X-Cell-Id", entry.cellId);
    proxied.headers.set("X-Tenant-Id", tenantId);

    return fetch(proxied);
  }

  private async resolveWithCache(tenantId: string): Promise<RoutingEntry | null> {
    const cached = this.cache.get(tenantId);

    if (cached && Date.now() - cached.updatedAt < this.cacheTtlMs) {
      return cached;
    }

    const fresh = await this.routingTable.get(tenantId);

    if (fresh) {
      this.cache.set(tenantId, { ...fresh, updatedAt: Date.now() });
    }

    return fresh;
  }

  private extractTenantId(req: Request): string | null {
    // Subdomain: tenant-slug.example.com
    const host = req.headers.get("host") ?? "";
    const subdomain = host.split(".")[0];
    if (subdomain && subdomain !== "www") return subdomain;

    // Header fallback for API clients
    return req.headers.get("X-Tenant-Id");
  }
}

The routing layer must be stateless and horizontally scalable. It cannot be a single point of failure in a system designed to eliminate single points of failure.

Cell-Aware Deployments

Standard deployment pipelines push changes to all instances simultaneously or in rolling waves across a fleet. With cells, you deploy one cell at a time and observe before proceeding.

The deployment sequence:

  1. Deploy to a canary cell (typically your lowest-traffic cell or a dedicated canary cell with no real tenants)
  2. Hold for an observation window (10-30 minutes minimum, longer for major changes)
  3. Check error rates, latency p99, and DB query times for the canary cell against baseline
  4. If metrics look clean, proceed to the next cell
  5. Continue cell by cell, with automated gates between each step
interface DeploymentPlan {
  version: string;
  cells: string[];         // ordered deployment sequence
  observationWindowMs: number;
  rollbackOnErrorRateAbove: number;  // e.g. 0.01 for 1%
}

async function executeCellDeployment(plan: DeploymentPlan): Promise<void> {
  for (const cellId of plan.cells) {
    console.log(`Deploying ${plan.version} to ${cellId}`);

    await deployToCell(cellId, plan.version);
    await wait(plan.observationWindowMs);

    const metrics = await getCellMetrics(cellId, plan.observationWindowMs);

    if (metrics.errorRate > plan.rollbackOnErrorRateAbove) {
      console.error(
        `Cell ${cellId} error rate ${metrics.errorRate} exceeds threshold. Rolling back.`
      );
      await rollbackCell(cellId);
      throw new Error(`Deployment halted at ${cellId} due to elevated error rate`);
    }

    console.log(`Cell ${cellId} healthy. Proceeding.`);
  }
}

The key property is that a bad deploy can only affect the cells it has already been deployed to. If it fails at cell-02, cells 03 through N are still running the previous version and serving tenants normally.

Cross-Cell Data Replication

Most data lives in the owning cell and never crosses cell boundaries. Tenant data is cell-local by design.

There are two categories of data that do need to cross cells:

Global reference data: product catalog, feature flag configurations, pricing tables, and similar read-heavy, write-rare data. This typically flows from a global control plane to all cells via a push or pull replication mechanism. Cells cache this data locally and it is acceptable for it to be eventually consistent with a lag of seconds to minutes.

Analytics and reporting data: tenant-scoped data that your product team or your tenants need to query across cell boundaries. The standard approach is event streaming, where each cell publishes domain events to a shared event bus (Kafka or Kinesis), and a separate analytics pipeline consumes those events into a global data warehouse. Cells never query each other directly.

// Events published from cells are cell-tagged for lineage tracking
interface DomainEvent<T> {
  eventId: string;
  cellId: string;
  tenantId: string;
  eventType: string;
  occurredAt: Date;
  payload: T;
}

async function publishEvent<T>(
  eventType: string,
  tenantId: string,
  payload: T,
  cellConfig: CellConfig
): Promise<void> {
  const event: DomainEvent<T> = {
    eventId: crypto.randomUUID(),
    cellId: cellConfig.cellId,
    tenantId,
    eventType,
    occurredAt: new Date(),
    payload,
  };

  await eventBus.publish({
    topic: `cell-events.${cellConfig.region}`,
    key: tenantId,  // partition by tenant for ordering guarantees
    value: JSON.stringify(event),
  });
}

The pattern breaks down when teams start building cross-cell synchronous queries. If service A in cell-01 needs to call service B in cell-03 to fulfill a request, you have reintroduced cross-cell coupling. When cell-03 is degraded, requests to cell-01 now fail too. Enforce a strict rule: cells communicate only through async events, never through synchronous RPC.

Tradeoffs

FactorCell-BasedMulti-AZ Only
Blast radiusBounded to one cellEntire service
Infrastructure costProportional to cell countLower at same tenant count
Operational complexityHigh: N cell configs, N deploy pipelinesLow: single fleet
Noisy neighbor isolationStrongNone
Cross-tenant queriesRequires event pipeline or external storeTrivial
Tenant migrationsExplicit, disruptive, requires toolingNot needed
Deploy safetyVery high with cell-by-cell rolloutModerate with rolling deploy
Time to onboard new cellHours to daysMinutes

The cost implications deserve explicit attention. Running 10 cells instead of one multi-AZ cluster means paying for 10 database instances, 10 cache clusters, and 10 separate autoscaling groups. At low tenant counts, this is wasteful. At high tenant counts with SLA commitments, the isolation is worth it.

Tenant Migrations Between Cells

Tenants occasionally need to move between cells: when a cell approaches capacity, when you want to consolidate lightly-loaded cells, or when a tenant upgrades to a tier with dedicated infrastructure.

A cell migration is a read-your-writes-consistency problem. The sequence must be:

  1. Put the source cell into read-only mode for the migrating tenant (or queue writes during migration)
  2. Copy the tenant’s data to the destination cell
  3. Verify data integrity with a checksum comparison
  4. Update the routing table to point to the destination cell
  5. Drain the routing cache TTL before re-enabling writes
  6. Delete the tenant’s data from the source cell after a retention window

Skipping the routing cache drain (step 5) means some routers will continue sending traffic to the old cell after the routing table update, causing stale reads or write conflicts. Build this wait explicitly into your migration tooling.

When This Pattern Is Worth It

Cell-based architecture is not appropriate for most services. It is a high-complexity, high-cost pattern that pays off under specific conditions:

Strong signal to adopt it:

  • You have SLA commitments per tenant and one misbehaving tenant cannot be allowed to breach another tenant’s SLA
  • You operate at a scale where a single application tier routinely experiences noisy-neighbor effects
  • You need the ability to deploy changes to a fraction of tenants to validate behavior before broad rollout
  • Your tenant base is large enough that the infrastructure overhead is a small fraction of total cost

Signal to stick with multi-AZ:

  • You have fewer than a few hundred tenants
  • Your tenants are homogeneous in their resource usage
  • You can afford brief correlated outages and recover fast
  • Your team does not have operational bandwidth to manage multiple independent deployment pipelines

A common mistake is building cells prematurely, attracted by the architectural elegance, and then discovering the operational burden at low scale. Most services are better served by a well-tuned multi-AZ deployment with per-tenant resource limits enforced at the application layer (rate limiting, query timeouts, connection pool quotas) until the scale genuinely demands isolation.

Production Observations

A few things that come up consistently when operating cell-based systems:

Cell health checks need to be cell-specific. Your global load balancer health check hitting /health tells you nothing about the state of cell-03’s database. Each cell needs its own health endpoint that checks its local dependencies, and your deployment automation needs to gate on cell-level health, not global availability.

The routing layer becomes a critical dependency. It sits in front of everything. It needs its own redundancy, its own capacity planning, and its own observability. Treat it as carefully as you would treat your database.

Observability across cells requires discipline. Without consistent metric naming and tagging, debugging a cross-cell incident is painful. Enforce cell_id as a required dimension on every metric, log, and trace from day one. Retrofitting this is expensive.

Provisioning automation is non-negotiable. If spinning up a new cell requires manual steps, you will never do it. The entire cell lifecycle, provisioning, configuration, health validation, and decommissioning, must be automated.

Cell-based architecture shifts your failure mode from “the whole service is down” to “a bounded fraction of your tenants are affected.” For services with strong isolation requirements, that shift is worth the investment. For everything else, solve noisy-neighbor problems at the application layer before reaching for a new architectural layer.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.