Article / System Design

Designing a learning platform for one million users

Work from explicit traffic assumptions to capacity estimates, database access patterns, caching, durable writes, background jobs, and recovery planning.

Aayush Raj Regmi9 min read
In this article · 10 sections

“Design a system for one million users” is an incomplete requirement. One million accounts could mean a quiet document archive, a classroom platform with synchronized exam traffic, or a messaging service with millions of persistent connections. Each has a different critical path.

This walkthrough uses an illustrative learning platform: course pages, authenticated enrollment, progress updates, assignment metadata, and asynchronous notifications. It develops a plausible architecture from explicit workload assumptions. The numbers are planning examples, not benchmarks or a claim that a particular server configuration can support this traffic.

Define the user journeys and guarantees

Start with the operations that matter. A learner browses a course, enrolls, opens learning material, records progress, and submits an assignment. An educator edits content and reviews work. An administrator manages access.

Separate correctness requirements from convenience. Enrollment must not allocate the same final seat twice. A progress update should survive a retry. A search index may be slightly behind the primary database. Email can arrive later without invalidating enrollment. Private submissions must remain authorized even if their metadata is cached.

Also define objectives. For example, choose an initial read-latency target and a monthly availability objective, then state which request types and failure categories count. These are product decisions to negotiate and measure, not numbers that become true when written into a diagram. Google’s guidance on implementing SLOs is a useful framework for making those definitions operational.

Turn one million accounts into a workload

Assume one million registered users, 100,000 daily active users, thirty read API requests and three write API requests per active user per day, and a peak twenty times the daily average. These assumptions should later be replaced by telemetry.

QuantityIllustrative calculationResult
Daily API reads100,000 × 303,000,000
Average reads per second3,000,000 ÷ 86,400about 34.7
Peak reads per second34.7 × 20about 694
Daily API writes100,000 × 3300,000
Peak writes per second(300,000 ÷ 86,400) × 20about 69

The peak multiplier is a placeholder, not a law. A scheduled exam can produce a much sharper spike than normal browsing. Model synchronized login and submission separately from a smooth daily average.

If the combined peak arrival rate is about 763 requests per second and average time inside the API is 0.2 seconds, Little’s Law suggests approximately 153 in-flight API requests at that operating point. That is not 153 logged-in users. It is the average number of requests currently inside the selected API boundary under a stable workload.

Estimate storage by data category. At 300,000 writes a day and an illustrative 800 bytes of new logical data per write, raw growth is about 240 MB per day, or 87.6 GB in 365 days using decimal units. Indexes, replicas, logs, backups, and temporary operations add separate overhead. Not every update creates a permanently retained 800-byte record, so validate this assumption against the actual schema and retention rules.

Video and assignment files are a separate bandwidth and storage problem. Serving a multi-megabyte file through the API changes the workload dramatically. Account for media delivery separately rather than hiding it inside an average JSON request size.

Begin with a system you can operate

A reasonable first architecture is a modular application behind a load balancer, a transactional relational database, object storage, a CDN for public assets, and a durable queue with background workers.

flowchart LR
  Browser["Browser"] --> CDN["CDN"]
  CDN --> Assets["Public assets and course media"]
  Browser --> LB["Load balancer"]
  LB --> App["Stateless application instances"]
  App --> DB[("Primary database")]
  App --> Store["Object storage"]
  DB --> Outbox["Outbox"]
  Outbox --> Queue["Queue"]
  Queue --> Workers["Workers"]

The diagram expresses responsibilities, not a required vendor stack. Use managed components where their operational trade-offs fit the team. A small team can gain more reliability from a well-understood database and clear recovery procedure than from adopting a complex distributed platform prematurely.

Application instances should not depend on local memory for durable sessions or jobs. Share session state where needed or use a suitable signed-session design, and make its revocation and security properties explicit. Keep uploaded files outside ephemeral application disks.

Model ownership and access patterns

Core entities include users, courses, lessons, enrollments, progress records, and submissions. A unique learner-course relationship prevents duplicate enrollment. A progress record might be unique by learner and lesson. Submission records reference object-storage keys rather than embedding large files in the relational row.

Tenant boundaries, if the platform serves several institutions, belong in both authorization and query design. A request must establish which institution and resources the authenticated user can access. Do not trust a client-supplied institution identifier without checking membership.

Index the queries that implement important journeys. A learner’s recent activity can use a compound index and keyset pagination:

CREATE INDEX activity_learner_recent
ON activity (learner_id, created_at DESC, id DESC);

-- Parameterized query: $1 learner, $2 cursor timestamp, $3 cursor ID.
SELECT id, created_at, event_type
FROM activity
WHERE learner_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;

The first page omits the cursor condition. The ID breaks ties when timestamps match. This avoids repeatedly skipping a growing number of rows with a deep offset, though it does not create a frozen snapshot of a changing feed. Choose snapshot semantics separately if the product needs them. PostgreSQL documents the behavior of multicolumn indexes; inspect the actual query plan on realistic data.

Scale reads according to their consistency needs

Cache public course metadata where freshness requirements permit it. Use the CDN for immutable assets and versioned media URLs. Keep private responses out of shared public caches unless the cache boundary and authorization behavior are explicitly designed for them.

Suppose 80% of read requests are cache-eligible and that subset achieves a 90% hit rate. The fraction of reads reaching the origin is approximately:

(1−0.8)+0.8(1−0.9)=0.28.(1-0.8)+0.8(1-0.9)=0.28.

The illustrative average origin read rate falls from about 34.7 to 9.7 requests per second; the corresponding peak is about 194. These figures ignore refresh and invalidation traffic, and a cache miss storm can temporarily erase much of the benefit. Test cold caches and popular-key expiry.

A read replica can serve some reporting or browse queries. It is not automatically appropriate for a learner checking an enrollment immediately after a successful write. Decide which operations need read-after-write consistency and route those deliberately.

Make writes durable and repeatable

A mobile client may retry a submission when its network connection drops. The server must distinguish a repeated delivery of one intended operation from a genuinely new submission. Define an idempotency contract: scope the key to the authenticated principal and operation, associate it with the request payload, and reject incompatible reuse.

An enrollment transaction should establish eligibility, allocate capacity atomically, create the enrollment, and record any required local outbox event. Commit the local state before attempting external delivery. The transaction boundary protects a specific set of local guarantees; it does not include the email provider simply because both actions belong to one user journey.

The AWS Builders’ Library article on making retries safe with idempotent APIs explains the importance of identifying caller intent. Your application still needs a retention policy for keys and a clear answer for requests retried after that retention window.

For progress updates, choose semantics that tolerate reordering. A “highest completed lesson” counter may not represent branching courses correctly. A per-lesson completion record can be easier to reason about. Store event time separately from processing time when delayed synchronization matters.

Move slow work off the interactive path

Notifications, report generation, media processing, and search indexing are natural background tasks. Return an accepted job identifier when a task is asynchronous, and provide a status endpoint or user-visible completion state. An HTTP success response must not imply that a report is finished when it is only queued.

Workers need retry policies, deduplication, deadlines, and a destination for jobs that repeatedly fail. Separate interactive notifications from bulk imports. A large import should not consume every worker slot required for a password-reset or enrollment confirmation.

Monitor oldest-job age and completion latency. Increasing worker count helps only while the dependencies can handle the additional load. If all workers contend on the same database rows, doubling them may increase lock waits rather than useful throughput.

Design for failure and recovery

Run enough independent application capacity that one instance can fail without stopping the service. Bound connection pools across the whole fleet. Add readiness checks that reflect whether an instance can serve useful traffic, and avoid making every optional dependency a reason to remove every instance from service.

Backups require restore tests. Specify a recovery point objective, describing acceptable data loss, and a recovery time objective, describing acceptable restoration time. A successful nightly backup job does not demonstrate that the team can recover the database within those targets.

Use timeouts and bounded concurrency to prevent slow dependencies from exhausting the application. Prioritize essential journeys during overload. Preserving access to lessons and accepting durable submissions may matter more than generating recommendations or administrative exports. Google’s handling-overload chapter provides useful context for deliberate degradation.

Observe user outcomes alongside infrastructure: enrollment success, submission durability, lesson-opening latency, queue age, and error-budget consumption. Logs and traces should connect an operation across components without indiscriminately storing private submission content.

Evolve when evidence identifies the next constraint

At modest traffic, improve queries, payload sizes, caching, and background isolation first. At higher sustained demand, add application capacity and distribute appropriate reads. Partition data or introduce specialized storage when measured access patterns, retention, and operational requirements justify it.

Extract a service when it has a useful ownership boundary and needs independent scaling or deployment. Media processing is often a clearer candidate than splitting tightly coupled enrollment tables across several services. A service boundary introduces network failures, versioned contracts, and distributed data consistency; it should buy something worth those costs.

A million accounts does not force database sharding. Nor does this walkthrough prove one database will always suffice. The decision depends on write volume, working set, query plans, contention, availability requirements, and hardware. Measure the limiting resource instead of choosing a fashionable architecture by user-count threshold.

Practice: defend the design with numbers

Replace the assumptions in the workload table with three scenarios: ordinary weekday, exam opening, and assignment deadline. Estimate API load, file bandwidth, and queue arrival rates separately. Define which operations can degrade and which must retain their guarantees.

Load-test the critical paths with a realistic dataset, a cold cache, one failed application instance, and a slow background dependency. Test a restore into a clean environment. A defensible system design explains why each component exists, which guarantee it provides, and what measurement would tell you it is time to change it.

Sources & further reading

The explanations and worked examples in this article are original. These papers and official references support the technical background and provide further detail.

  1. implementing SLOs ↗
  2. multicolumn indexes ↗
  3. making retries safe with idempotent APIs ↗
  4. handling-overload chapter ↗
Back to all writing