πŸ“š Study Notes / Home / HLD / Session 15
Session 15 Β· NoSQL Types & Messaging 2

Picking the right NoSQL database β€” and using it for chat

"NoSQL" isn't one thing β€” it's a family of four very different database styles, each good at a different job. In this session we'll meet all four with everyday analogies, see real data models and queries for each, build a simple rule for choosing between them, and then put it to work: we'll revisit the messaging app from Session 14 and decide exactly which store should hold our messages, presence, and inbox. Take it slow β€” by the end you'll be able to say "use this database, because…" and actually mean it.

⏱ 36 min readπŸ“– 6 topics

1 The NoSQL landscape


Explain like I'm 5

Imagine your kitchen. You don't keep everything in one kind of container. Spices go in tiny labelled jars, leftovers go in stackable boxes, and a long spaghetti noodle needs a tall thin jar. Each container is shaped for what it holds. NoSQL databases are just different-shaped containers for data. A relational (SQL) database is like one giant set of identical drawers with strict labels β€” wonderful, but not the right shape for everything. NoSQL gives you more container shapes to choose from.

First, a quick reminder of why NoSQL exists at all. Recall from our earlier database sessions that a relational database (SQL β€” like PostgreSQL or MySQL) stores data in tables with fixed columns, enforces a strict schema, and is brilliant at JOINs and transactions. That model dominated for decades. So what went wrong?

Why NoSQL came along

Three pressures pushed teams past the limits of a single SQL server:

  • Scale. A relational database is easiest to run on one big machine (vertical scaling β€” buy a bigger server). But there's a ceiling: the biggest machine money can buy. The web needed to spread data across many cheap machines (horizontal scaling β€” add more servers), and JOINs across machines are painfully slow.
  • Flexible shape. A strict schema means every row has the same columns. But a product catalogue (a book vs. a TV vs. a t-shirt) or a user profile that keeps gaining fields doesn't fit neatly. A flexible/schemaless model lets each record carry its own shape.
  • Speed for specific patterns. If you always look data up by one key (a user ID, a session token), you don't need the full power of SQL β€” you need a lookup that's blisteringly fast. Specialised stores win here.
"NoSQL" doesn't mean "no SQL"

It originally meant "non-relational," and many people now read it as "Not Only SQL." Plenty of NoSQL databases even offer SQL-like query languages today. The real distinction is the data model and how it scales β€” not whether the query happens to look like SQL.

The four families

Almost every NoSQL database falls into one of four families, organised by the shape of data they store:

πŸ”‘
Key-Value
key β†’ blob
Β·
πŸ“„
Document
key β†’ JSON doc
Β·
🧱
Wide-Column
row β†’ many columns
Β·
πŸ•ΈοΈ
Graph
nodes + edges
FamilyStores data as…Famous examplesOne-line job
Key-ValueA simple key pointing to a value (any blob).Redis, DynamoDB, MemcachedUltra-fast lookups by one key.
DocumentA key pointing to a structured JSON-like document.MongoDB, Couchbase, FirestoreFlexible records you query by their fields.
Wide-ColumnRows that can each have huge, varying sets of columns, grouped by a partition key.Cassandra, HBase, ScyllaDB, BigtableMassive write throughput across many machines.
GraphNodes (things) connected by edges (relationships).Neo4j, Amazon NeptuneFollowing relationships ("friends of friends").
The big idea

There is no single "best" database. Each NoSQL family trades away some abilities (JOINs, strict schema, strong consistency) to be exceptional at one access pattern. Your job as a system designer is to match the family to how you actually read and write the data β€” which is exactly the decision framework we'll build in Topic 4.

Be careful about claimed features

Each NoSQL type specialises for specific features and use-cases. But be sceptical of the long feature lists that popular NoSQL databases advertise. The original, "pure" model of each family has clear strengths and clear weaknesses β€” and most modern products quietly violate the pure model to claim extra features. Learn the pure model first; then you can judge what a vendor's "also does X" really costs.

Addressability β€” the unit you read and write

A subtle but important idea ties the whole family together: every storage layer has a unit of addressability β€” the smallest chunk it can read or write at once. Why? Because all physical memory (HDD, RAM, CPU cache, registers) is byte-addressable, never bit-addressable. That's why a boolean β€” logically 1 bit β€” actually takes 1 byte (8 bits, 7 wasted) in Java/C/C++/Rust, and 4 bytes in Python: the hardware simply cannot read or write a single bit. Each database family inherits its own version of this:

FamilyAddressable unitMeaning
Key-ValueThe whole entry (key-addressable)Read/write the entire value β€” unless you use special Redis datatypes.
Relational (SQL)The whole row (row-addressable)Read/write an entire row at a time.
DocumentThe whole document (document-addressable)Read/write the entire document β€” you cannot touch one nested attribute alone.
Key takeaway

NoSQL is not a rejection of SQL β€” it's a toolbox of data shapes (key-value, document, wide-column, graph, plus large-file/object and niche types like vector and graph) that scale horizontally and stay flexible. Big systems usually mix several of these and SQL together β€” this is called polyglot persistence (using the right database for each job).

Recap NoSQL exists because the web needed horizontal scale, flexible schemas, and speed for specific access patterns that a single relational server struggles with. It comes in four families β€” key-value, document, wide-column, and graph β€” and the art is matching the family to your access pattern, often using several at once (polyglot persistence).

2 Key-Value & Document stores


Explain like I'm 5

A key-value store is a coat check at a theatre: you hand over your coat, they give you a numbered ticket, and later that exact number gets your exact coat back β€” instantly. They never look inside your coat; they just match the ticket. A document store is fancier: it's a coat check that also knows your coat is "a red wool jacket, size M, with a phone in the pocket" β€” so you can ask "give me all the red jackets," not just "give me ticket #42."

Part A β€” Key-Value stores (Redis, DynamoDB)

A key-value store is the simplest possible database: a giant dictionary. You store a value under a unique key, and you get it back by handing over that key. The database treats the value as an opaque blob β€” it usually doesn't look inside it or let you query by its contents.

OperationMeaningSpeed
SET user:42 "{...}"Store a value under key user:42O(1) β€” constant time
GET user:42Fetch the value for that keyO(1) β€” constant time
DEL user:42Remove the keyO(1) β€” constant time

In the pure model, both the key and the value are just plain strings β€” the database doesn't know and doesn't care what's inside them. (Most modern key-value stores violate this: Redis values can be arrays, JSON, sets, sorted sets, bloom filters, even custom data structures, while the key stays a string.) A toy example:

Key (string)Value
"contest:13:page:10"{ ["rank":130, ...], [...], [...] }
"contest:13:winner"1361

Because the only thing it does is "look up by key," it can be extraordinarily fast β€” and because it's typically in RAM, ridiculously so. The numbers are striking:

StoreThroughput (rough)
A single Redis server100,000+ reads & writes / second
A plain SQL server~100 writes / sec, ~1,000 reads / sec
A well-optimised Postgres servera few thousand reads/writes / sec

Three famous examples sit at different points on the in-memory/on-disk spectrum:

  • Redis β€” in-memory + optional disk persistence, so reads and writes are microseconds. The go-to for caching, session storage, rate limiting, and leaderboards. It also offers richer value types (lists, sets, sorted sets, hashes, bloom filters) β€” handy, as we'll see in the messaging follow-up.
  • Memcached β€” purely in-memory, no persistence; a classic, very simple cache.
  • DynamoDB (AWS) β€” disk persistence, managed and infinitely scalable. You give it a key, it spreads your data across countless machines automatically, and it stays fast at any size. Great for huge, predictable key lookups like shopping carts and user sessions.

The core API is just three operations:

The three core key-value operations
get(key)            # β‡’ value
set(key, value)     # β‡’ ack / failure
delete(key)         # β‡’ ack / failure
Worked example: the counter race condition

Suppose you store a counter in a key-value DB and want to increment it. The naive way is to read, add one, and write back in your application code:

# DON'T do this β€” it has a race condition
value = key_value_db.get(key)
value += 1
key_value_db.set(key, value)

If two requests run this at the same time, both read the same old value and both write old+1 β€” one increment is silently lost. The fix: do it atomically. A plain increment is conceptually get + set, but Redis exposes it as a single INCR operation that the server performs atomically:

INCR key            # atomic: no lost updates

Modern stores like Redis let you do far more than the three simple operations precisely so you can avoid these read-modify-write traps.

Worked example: caching a user session in Redis

When a user logs in, you store their session so every later request can be checked instantly without hitting the main database:

# Store a session, auto-expiring after 1 hour (3600 seconds)
SET session:a1b2c3 "{\"userId\":42,\"name\":\"Mei\"}" EX 3600

# Later, on every request: who owns this session token?
GET session:a1b2c3
β†’ "{\"userId\":42,\"name\":\"Mei\"}"   (returned in microseconds)

# A leaderboard using a Redis sorted set
ZADD leaderboard 980 "Mei"
ZADD leaderboard 1240 "Raj"
ZREVRANGE leaderboard 0 2 WITHSCORES   # top 3 players

Notice the keys carry a prefix (session:, user:) β€” a common convention, since the database itself gives you no tables to organise things.

Weaknesses (the price of that simplicity): no complex queries (no joins, no filtering), no search, no indexing, no relations. There's also no separate sharding key β€” the data is automatically sharded by hash(key), so the sharding key = primary key = the key itself.

When to use key-value: you always (or almost always) look up data by a single known key, you need extreme speed, and you don't need to query inside the value. Caching (global, single, or distributed), and storing very simple key→value data that's queried extremely frequently: user preferences, rules, rate-limiter bucket counts, view counts.

The trade-off

You can't ask "find all users in London" β€” the store can't see inside the value. If you ever need to query by the contents, a pure key-value store is the wrong shape. That's exactly the gap document stores fill.

How big should keys and values be?

In Redis a single string can be up to 500 MB β€” but should you store 500 MB per entry? Absolutely not. As a rule of thumb, keep keys ≲ 100 bytes and values ≲ 10 KB. If your keys are longer than ~100 bytes, a different database is probably a better fit. If your values are larger than ~10 KB, you likely need to look inside the value β€” and key-value is the wrong choice.

Part B β€” Document stores (MongoDB)

A document store keeps each record as a self-contained document β€” typically JSON (or its binary cousin BSON in MongoDB). Unlike key-value, the database understands the document's structure, so you can query by any field and even index those fields. Documents are grouped into collections (loosely like SQL tables, but without a forced schema).

The superpower of documents is nesting: related data can live inside one document instead of being split across many tables. A blog post can carry its author info, tags, and comments all in one place β€” no JOIN needed to read it. The other examples in this family are ElasticSearch, Couchbase, and Firestore. Think of a document store as a collection of JSON/JSONB files distributed across many servers.

A single document is never sharded

A single document is always stored completely within one server (and replicated to others) β€” it is never split across machines. Every document has a unique _id, and any document can carry any set of attributes. In MongoDB the _id is generated on the client side when the document is inserted (often a UUIDv4).

Worked example: two very different products, one collection

An Amazon-style product catalogue is the classic case: a t-shirt and a laptop share almost nothing, yet both live happily in the same collection because there is no forced schema.

{                              {
  _id: uuidv4                    _id: uuidv4
  product_id: int                product_id: int
  name: string                   name: string
  type: "t-shirt"                  type: "laptop"
  brand: string                  brand: string
  color: string                  color: string
  neck_type: byte                ram: {
  sleeve_length: byte              size: integer
  image_url: string                technology: "ddr4/ddr5"
}                                  cas_latency: string
                                 }
                                 cpu: string
                                 image_url: [string, string, string]
                               }

The t-shirt has neck_type / sleeve_length; the laptop has a nested ram object and an array of image URLs. Neither carries the other's fields, and the database is perfectly happy.

Worked example: a blog post as a nested document
{
  "_id": "post_8842",
  "title": "Why we chose NoSQL",
  "author": { "id": 42, "name": "Mei" },
  "tags": ["databases", "nosql", "backend"],
  "published": true,
  "views": 1503,
  "comments": [
    { "user": "Raj",  "text": "Great post!", "likes": 4 },
    { "user": "Ana",  "text": "Helpful, thanks.", "likes": 1 }
  ]
}

One read gives you the whole post, its author, its tags, and its comments. In a relational design this would be posts, authors, tags, and comments tables stitched with JOINs.

And because the store understands fields, you can query them richly. MongoDB's query language is itself JSON-shaped:

Example MongoDB queries
// Find all published posts tagged "nosql", newest first
db.posts.find(
  { published: true, tags: "nosql" }
).sort({ views: -1 })

// Add a new comment to a post (push into the nested array)
db.posts.updateOne(
  { _id: "post_8842" },
  { $push: { comments: { user: "Sam", text: "Nice!", likes: 0 } } }
)

// Index the tags field so the query above is fast
db.posts.createIndex({ tags: 1 })
Different documents, same collection β€” and who enforces the schema

Because there's no forced schema, one document can have a discount field while its neighbour doesn't. Great for evolving, naturally-varied data (product catalogues, user profiles). The catch: the schema must be enforced by the developer at the app-server level β€” the database enforces nothing. Sloppy code leaves a messy mix of shapes.

Indexes are local β€” there is no global index (fan-out reads)

Document stores give powerful query & search via indexes (B+ trees), and you can index any top-level (non-nested) attribute. But indexes are maintained locally on each shard β€” there is no global index. The consequence:

  • find({brand: "dell"}) on a catalogue sharded by something other than brand becomes a fan-out / broadcast read: the query goes to every shard, and each shard uses its local "brand" index to return matches.
  • find({brand: "dell", type: "laptop"}) will not fan out if the collection is sharded by brand or type β€” the query can be routed straight to the right shard.

Document stores also (typically) have no relations, no joins, no global indexes, and (typically) no ACID transactions. MongoDB is a notable exception: it offers ACID even across shards β€” but beware, ACID within a shard is fast, while ACID across shards is extremely slow.

Primary key vs sharding key

Primary key = _id. The default sharding key is also _id, but any top-level attribute (or a composite of them) can be configured instead. For the Amazon catalogue you might shard by product_type (laptop / t-shirt / …) so same-type products cluster together.

When to use document stores: your data is naturally nested/object-shaped, the schema evolves often, you need full-text search, and you mostly read a whole record at once but still need to query by its fields. Amazon product listings, social-media posts, user notes, LinkedIn job postings, user reviews, content management, and (very commonly) the "main object store" for a web/mobile app.

How big should a document be? Why the hard limit?

Keep documents ≲ 10 MB; MongoDB enforces a hard cap of 16 MB per document. Why such a hard limit? Because a document store is document-addressable: every read/write happens at the whole-document level. Change even one character of one attribute in a 16 MB document and the database rewrites the entire document. Likewise a read always pulls the whole document off disk β€” you cannot read or write a single attribute in isolation. (Same reason a 1-bit boolean costs a whole byte: hardware is byte-addressable, not bit-addressable.)

Key-value vs document β€” the distinction

Both look data up by a key. The difference is visibility: a key-value store treats the value as a sealed box (fast, but query-by-key only); a document store can see inside the box and query/index its fields. Document stores are, in a sense, "smart key-value stores."

Recap Key-value stores (Redis, DynamoDB) are dictionaries: lightning-fast O(1) lookups by a single key, value treated as an opaque blob β€” perfect for caches, sessions, counters. Document stores (MongoDB) keep flexible, nested JSON documents the database understands, so you can query and index by field β€” perfect for evolving, object-shaped records. Use key-value when you only ever fetch by key; use documents when you need to query the contents.

3 Wide-Column & Graph stores


Explain like I'm 5

A wide-column store is like a huge filing cabinet split across many rooms. A label on each folder (the partition key) tells you which room to walk into, and inside that folder the papers are kept in a fixed order so you can flip straight to the one you want. Because the rooms are separate, a hundred people can file papers at once without bumping into each other. A graph database is completely different: it's like a map of friendships drawn with dots and lines. To find "friends of my friends," you just follow the lines β€” no searching, just walking the connections.

Part A β€” Wide-Column stores (Cassandra, HBase)

"Wide-column," "column-family," and "columnar" all name the same thing. The data is still tabular like a relational DB, but stored in wide-column format (column-wise) instead of SQL's row-wise layout β€” which makes aggregate queries (sum/avg over a few columns across many rows) very fast. There are still no joins, and no relations between tables. Time-series databases are just a subset (a special type) of wide-column databases.

The headline examples are Cassandra (popular, peer-to-peer, no single master), BigTable (the first NoSQL database ever), ScyllaDB, and HBase (built on Hadoop). They absorb enormous write volumes across hundreds of machines with no single point of failure. Note that every column-family database models data quite differently; the common thread is that all of them store data in tables that are partitioned (sharded) across servers.

The whole model revolves around two kinds of keys baked into the primary key:

Key partWhat it doesAnalogy
Partition keyDecides which machine (node) the row lives on. Same partition key β†’ same machine, stored together.Which room in the filing cabinet.
Clustering keyDecides the sort order of rows within a partition.The order of papers inside that folder.
The golden rule of wide-column

You design the table around the query you want to run, not the other way around. There are essentially no JOINs and no ad-hoc filtering. You pick a partition key so the data you read together lives together, and a clustering key so it's already in the order you'll read it. Get the keys right and reads are blazing; get them wrong and the data is scattered and slow.

Worked example: storing sensor readings in Cassandra (CQL)

Suppose we collect temperature readings from millions of devices and always ask "show me the recent readings for device X." We partition by device and cluster by time:

CREATE TABLE readings (
  device_id   text,        -- partition key: groups a device's data
  ts          timestamp,   -- clustering key: sorts within the device
  temperature float,
  PRIMARY KEY ((device_id), ts)
) WITH CLUSTERING ORDER BY (ts DESC);   -- newest first

-- Writes scale across the whole cluster (different devices β†’ different nodes)
INSERT INTO readings (device_id, ts, temperature)
VALUES ('sensor-7', '2026-06-20 09:01:00', 21.4);

-- Read is a single fast hit: one partition, already time-sorted
SELECT ts, temperature FROM readings
WHERE device_id = 'sensor-7'
LIMIT 20;

Every device's readings live on a predictable node, already sorted newest-first β€” so that read touches one partition and returns instantly, even with billions of rows in the table.

Why are writes so fast? LSM trees

Wide-column stores aren't quite as fast as in-memory key-value, but they write much faster than most databases. The reason: they use LSM trees (Log-Structured Merge trees) and try to make writes sequential on disk β€” appending rather than seeking around to update in place. They also give easy time-based pagination almost for free, since data is already clustered in time order.

When to use wide-column: you have a huge volume of writes (high write throughput), a known and limited set of query patterns, and you need to scale across many machines with high availability. Analytics, sensor data (GPS coordinates, IoT telemetry), event/activity logs, paginated / time-based queries ("fetch a user's location history for the last month"), and β€” as we'll see β€” chat messages.

The trade-off

Flexibility on queries is sacrificed for scale. You can't easily run a query the table wasn't designed for; you often duplicate data into several tables, one per query pattern (this is normal and expected in Cassandra). And it typically offers tunable/eventual consistency rather than strong consistency by default β€” recall the CAP trade-offs from earlier sessions.

Part B β€” Graph databases (Neo4j)

A graph database stores data as a network: nodes (the things β€” people, products, places) connected by edges (the relationships β€” "FOLLOWS," "PURCHASED," "FRIENDS_WITH"), and both nodes and edges can carry properties. The star example is Neo4j.

Why a whole separate family? Because relationship-heavy questions are painful in other models. "Friends of friends of friends" in SQL means JOINing a table to itself three times β€” each JOIN gets exponentially slower. In a graph database, you simply walk the edges, and the cost depends on how many connections you actually traverse, not on the total size of the database. This is sometimes called index-free adjacency: each node directly points at its neighbours.

Worked example: friend recommendations in Neo4j (Cypher)

Cypher's syntax literally draws the pattern with arrows. Here we find people my friends know that I don't yet:

// Create some people and friendships
CREATE (mei:Person {name: 'Mei'}),
       (raj:Person {name: 'Raj'}),
       (ana:Person {name: 'Ana'}),
       (mei)-[:FRIENDS_WITH]->(raj),
       (raj)-[:FRIENDS_WITH]->(ana);

// "People my friends know, that I'm not already friends with"
MATCH (me:Person {name: 'Mei'})-[:FRIENDS_WITH]->(friend)-[:FRIENDS_WITH]->(suggested)
WHERE NOT (me)-[:FRIENDS_WITH]->(suggested) AND me <> suggested
RETURN suggested.name, count(*) AS mutualFriends
ORDER BY mutualFriends DESC;
// β†’ Ana (1 mutual friend: Raj)

That arrow chain -[:FRIENDS_WITH]-> is the friendship being traversed. No self-JOINs, no exploding cost β€” just a walk along the lines.

When to use graph databases: when your queries require "path-finding." Recommender systems (Amazon, Netflix), shortest route between two places (Uber, Google Maps), fraud detection (rings of connected accounts), and knowledge graphs.

Famous, but rare in practice

Graph databases are famous partly because people have a weird attraction to graphs β€” but they are rare in production. Facebook friendships, LinkedIn connections, Twitter follows are all classic "social connection graphs," yet none of these companies stores the relationships in a graph DB β€” they keep the relations in SQL, and put a graph DB in front of the SQL store purely as a cache for the search / traversal queries.

Key takeaway

Wide-column and graph sit at opposite extremes. Wide-column (Cassandra) trades query flexibility for raw write scale across many machines β€” design the table around the query. Graph (Neo4j) trades that scale for cheap relationship traversal β€” when the connections are the data, nothing else comes close.

Recap Wide-column stores (Cassandra, HBase) use a partition key to spread rows across machines and a clustering key to sort within a partition β€” you design the table around the query and get massive write throughput, at the cost of query flexibility and (usually) eventual consistency. Graph databases (Neo4j) store nodes and edges so relationship questions like "friends of friends" become cheap edge walks instead of expensive JOINs. Use wide-column for huge, write-heavy, time-ordered data; use graph when the relationships are the point.

4 Large-File / Object & other families


Explain like I'm 5

The four families so far are for small bits of data. But what if you want to store a three-hour movie or a giant log file? That's like asking the spice-jar cupboard to hold a sofa β€” wrong shape entirely. For huge things you use a warehouse (object storage): you bring a big box, they keep it on a shelf, and later you fetch the whole box back. Then there are a few specialty shops β€” one just for maps of connections, one just for "find me something similar" β€” each great at one niche trick.

Large File / Object storage (S3, GCS, HDFS)

This family stores flat files directly on disk. A file is chunked and distributed across servers, and a single file can even be split across multiple machines. Examples: Amazon S3, Google Cloud Storage, Git Large File Storage (Git LFS), and the Hadoop Distributed File System (HDFS).

StrengthsWeaknesses
Files can be extremely large (a 100 TB log file is fine).No search, no relations.
Can stream the data.Hard to modify β€” you can append or replace, but not edit in place.
Cheap, durable, distributed.Reads and writes are slow.

When to use: any user-generated multimedia (PDFs, images, videos, audio, CSVs, zips, …); static client-side HTML/CSS/JS; and in general any large files (> ~10 KB) that are mostly static.

Other / niche families

Beyond the big families, "every kid and their grandmother have their own NoSQL database type." A few worth knowing:

TypeWhat it's forNotes
GraphPath-finding: recommender systems (Amazon/Netflix), shortest route (Uber/Maps).Famous but rare; usually a cache in front of SQL (see Topic 3).
VectorFast K-Nearest-Neighbour queries β€” searching over an embedding space.Increasingly popular thanks to AI.
Object-OrientedModelling objects directly, e.g. table inheritance.Postgres supports table inheritance.
MultimodalOne database offering several of the above models at once.Essentially all modern databases are multi-modal.
Multimodal & the feature-claims trap

Because almost every modern database now advertises multiple models, the feature lists blur together. Don't pick a database from its marketing checklist β€” pick it from its pure model's strengths and weaknesses, then verify it really delivers the one access pattern you care about.

Recap Large-file / object stores (S3, GCS, Git LFS, HDFS) hold huge, mostly-static flat files β€” great for multimedia and static assets, but slow, append-or-replace only, with no search. Beyond the main families there are niche types: graph (path-finding, often just a cache over SQL), vector (KNN over embeddings, booming with AI), object-oriented (table inheritance), and multimodal β€” which is really just "every modern database." Judge any of them by its pure model, not its feature checklist.

5 Choosing the right database


Explain like I'm 5

Picking a database is like picking a vehicle. You don't ask "what's the best vehicle?" β€” you ask "what am I doing?" Carrying one person to work? A bike. Moving a sofa? A van. Racing? A sports car. The trip decides the vehicle. For databases, the way you read and write your data β€” the access pattern β€” decides the database. So we start every choice by describing the trip, not by naming a favourite car.

There's no magic formula, but a few questions get you 90% of the way. Ask them in order:

The decision framework

πŸ”Ž
1. Access pattern
How do you read/write?
β†’
βš–οΈ
2. Consistency
Strong or eventual OK?
β†’
πŸ“ˆ
3. Scale
How big / how fast?
β†’
🧩
4. Shape
Relations? Nesting?
  • 1. Access pattern β€” how do you fetch the data? Always by one key? β†’ key-value. By fields inside a record? β†’ document or SQL. By following relationships? β†’ graph. By a partition + time range, write-heavy? β†’ wide-column.
  • 2. Consistency β€” do reads need to be perfectly up-to-date? Money, inventory, and bookings usually need strong consistency (often a relational DB or a strongly-consistent NoSQL config). A "like" count or a social feed can tolerate eventual consistency (it's fine if it's a second stale). Recall the CAP theorem trade-offs from our earlier sessions: under a network partition you choose consistency or availability.
  • 3. Scale β€” how much data, and how many reads/writes per second? If one beefy SQL server comfortably handles it, you may not need NoSQL at all. If you need to spread across many machines and absorb a firehose of writes, lean toward wide-column or DynamoDB.
  • 4. Shape β€” what does the data look like? Tabular with lots of relations β†’ relational. Self-contained nested objects β†’ document. A web of connections β†’ graph. A simple value behind a key β†’ key-value.
Don't reach for NoSQL by default

Relational databases are still the right answer for a great many systems β€” they give you JOINs, strong transactions (ACID), and a mature ecosystem. Choose NoSQL when a specific pressure (scale, shape, or speed for one pattern) actually demands it, not because it sounds modern. And remember polyglot persistence: real systems mix several.

The all-families comparison table

PropertyRelational (SQL)Key-ValueDocumentWide-ColumnGraph
Data modelTables & rowsKey β†’ blobKey β†’ JSON docPartition β†’ wide rowsNodes & edges
SchemaStrictNoneFlexibleFlexible per rowFlexible
Query byAnything (SQL, JOINs)Key onlyKey or any fieldPartition + clustering keyRelationships (traversal)
JOINsYes (strength)NoLimitedNoTraversal (its strength)
Scales byMostly verticalHorizontalHorizontalHorizontal (excellent)Harder to shard
ConsistencyStrong (ACID)TunableTunableTunable / eventualStrong (usually)
Sweet spotTransactions, relationsCaches, sessionsFlexible records, contentWrite-heavy, time-seriesConnected data
ExamplesPostgreSQL, MySQLRedis, DynamoDBMongoDBCassandra, HBaseNeo4j
Putting the framework to work β€” three quick calls
  • User shopping carts at huge scale, fetched by user ID. One-key access, eventual consistency fine, must scale β†’ key-value (DynamoDB).
  • A product catalogue where every product type has different fields. Query by fields, evolving shape β†’ document (MongoDB).
  • "People you may know" on a social app. Relationship traversal is the whole question β†’ graph (Neo4j).

Full worked example: storing tweets per hashtag

Let's run the framework end-to-end on a real Twitter feature.

Requirements
  • Store the most popular and most recent tweets for each hashtag.
  • Support paginated queries (first 20 tweets, next 20, …).
  • Handle a very large volume of tweet writes.

Now we test each family against those requirements:

  • SQL β€” no. The scale is too large. For a popular hashtag like #US-Elections-2025 or #Diwali, a single server can't even store all the tweets for one hashtag.
  • Key-Value β€” no. You'd use a key like "#Diwali2025:popular" or "#Diwali2025:recent" and the value would be the tweets. But the value is far too large (#Diwali can have 100M+ tweets β†’ multiple GBs), pagination is impossible (you can't fetch "the next 20" out of an opaque blob), and inserting a tweet means rewriting the whole "recent" value β€” far too costly.
  • Document DB β€” no, for the same reasons. Two shapes both fail: one giant document per hashtag holding a tweets: [...] array hits the document size cap and rewrites the whole document on every tweet; one document per tweet (__doc_id == tweet_id, with hashtag, content, author, like/view counts) fixes the size issue but still leaves you without the cheap time-ordered pagination you need.
  • Column Family β€” yes! (e.g. HBase) β€” all the requirements match exactly with the strengths of wide-column DBs: huge write throughput, and fast, time-ordered, paginated reads per hashtag.
The two document shapes that don't quite work
{                                  {
  __doc_id: ....                     __doc_id: == tweet_id
  hashtag: "Diwali 2025"           hashtag: "Diwali 2025"
  tweets: [                          content: "Crackerless diwali <3"
    {user_id: ..., ...},             author_id: "Krishna"
    {user_id: ..., ...},             likeCount: 1234
    {user_id: ..., ...},             viewCount: 12322
  ]                                }
}                                  // one doc per tweet: better, but
// (too big to fit)                    still no cheap pagination
Recap Choose a database by walking four questions in order: access pattern, consistency needs, scale, and data shape. Match the answer to the family's sweet spot using the comparison table β€” and remember that relational DBs are often still right, and that big systems happily mix several stores (polyglot persistence). Walking the Twitter-hashtag requirements ruled out SQL (scale), key-value (giant values, no pagination), and document (size + no pagination), landing squarely on a wide-column store. The access pattern, not fashion, drives the decision.

6 Messaging app follow-up


Explain like I'm 5

Think about a chat app like a post office. Messages are the letters β€” there are tons of them, they keep arriving, and you read them newest-first. Presence ("Mei is online") is a little light on each person's mailbox that flickers on and off β€” nobody minds if it's a second out of date. Your inbox is the list of which conversations have new letters. Each of these jobs is a different shape β€” so, just like in our kitchen analogy, each gets its own kind of container.

Back in Session 14 we sketched the architecture of a messaging app (think WhatsApp / Messenger). Now that we know the four NoSQL families, let's make the storage decisions concrete. We'll take three core pieces of data and choose a store for each β€” a perfect, real example of polyglot persistence.

Piece 1 β€” Messages: wide-column (Cassandra)

Messages are the heart of the system, and they have a very clear access pattern: an enormous volume of writes (billions a day), almost always read as "the recent messages in this conversation, newest first." That's the textbook case for a wide-column store. We partition by conversation and cluster by time β€” exactly the pattern from Topic 3.

Messages table in Cassandra (CQL)
CREATE TABLE messages_by_conversation (
  conversation_id  uuid,       -- partition key: all of one chat lives together
  message_id       timeuuid,   -- clustering key: time-ordered & unique
  sender_id        uuid,
  body             text,
  PRIMARY KEY ((conversation_id), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);  -- newest first

-- Send a message: a single fast write, lands on the conversation's partition
INSERT INTO messages_by_conversation (conversation_id, message_id, sender_id, body)
VALUES (conv_uuid, now(), mei_uuid, 'Hey, are you free tonight?');

-- Open a chat: read the latest 50 messages in one partition hit
SELECT sender_id, body, message_id FROM messages_by_conversation
WHERE conversation_id = conv_uuid
LIMIT 50;

Eventual consistency is acceptable here β€” if a message takes a few hundred milliseconds to replicate, the chat still feels instant. The huge write volume and the simple, time-ordered read pattern make Cassandra the natural fit. (This is essentially how Discord and others store messages at scale.)

Piece 2 β€” Presence: key-value in memory (Redis)

Presence is "who is online right now." It's tiny per user, changes constantly, is read extremely often, and β€” importantly β€” it's disposable: if it's a second stale, or even lost on a restart, nobody is harmed. That screams in-memory key-value (Redis) with an automatic expiry (TTL), so a user silently drops to "offline" if their app stops sending heartbeats.

Presence with Redis
# App sends a heartbeat every ~30s; key auto-expires after 60s
SET presence:user:42 "online" EX 60

# Is Mei online? (microsecond lookup)
GET presence:user:42
β†’ "online"   (or nil if her heartbeat stopped β†’ treated as offline)

# Optional: keep online friends in a set for "who's online" lists
SADD online:friends:42 7 19 88

The TTL is the elegant part: we never have to explicitly mark someone offline β€” we just stop refreshing the key and it disappears on its own.

Piece 3 β€” Inbox / conversation list: document store (MongoDB or DynamoDB)

A user's inbox is the list of their conversations with a bit of metadata each: the other participants, a preview of the last message, an unread count, and a timestamp for sorting. It's read every time the app opens, it's naturally an object-per-conversation, and the shape can vary (group chats vs. one-to-one). That's a great fit for a document store, fetched by the user's ID and sorted by most-recent activity.

An inbox entry as a document
{
  "_id": "inbox:user42:conv889",
  "userId": 42,
  "conversationId": "conv889",
  "participants": ["Mei", "Raj"],
  "lastMessage": { "text": "Hey, are you free tonight?", "from": "Mei" },
  "unreadCount": 3,
  "updatedAt": "2026-06-20T09:01:00Z"
}

// Load a user's inbox: their conversations, most recent first
db.inbox.find({ userId: 42 }).sort({ updatedAt: -1 })

When a new message arrives, we update the matching inbox documents (last message preview, bump updatedAt, increment unreadCount) β€” one small write per participant. DynamoDB is an equally valid choice here if you're on AWS and want managed key-based scaling.

The whole picture

DataAccess patternConsistencyChosen storeWhy
MessagesHuge writes; read recent-by-conversationEventual OKWide-column (Cassandra)Write scale + time-ordered reads
PresenceTiny, constant updates; read by userEventual / disposableKey-value in RAM (Redis)Speed + auto-expiry (TTL)
InboxRead by user, sorted by recencyEventual OKDocument (MongoDB / DynamoDB)Flexible per-conversation objects
The big idea

One feature, three different databases β€” each chosen by running the same framework from Topic 4 over a different piece of the data. This is polyglot persistence in the wild: messages go to Cassandra, presence to Redis, inboxes to a document store, and the user account / billing data we glossed over would happily sit in a relational database. No single store would serve all of these well.

Tying back to Session 14

In Session 14 we focused on the architecture β€” connections (WebSockets), delivery, and fan-out. This session fills in the storage layer behind that design. In Session 16 we'll do one more end-to-end case study (the IRCTC ticketing finale), where strong consistency and contention take centre stage β€” a deliberate contrast to the eventually-consistent choices we just made for chat.

Recap For a messaging app, no single database wins: messages β†’ wide-column (Cassandra) for write scale and time-ordered reads; presence β†’ in-memory key-value (Redis) for speed and TTL-based expiry; inbox β†’ document store (MongoDB/DynamoDB) for flexible, per-conversation objects. Each choice falls straight out of the access-pattern-first framework β€” a textbook example of polyglot persistence, building on the Session 14 architecture.

β˜… Putting it all together


You can now navigate the whole NoSQL world and make real storage decisions. Here's the one-paragraph story that connects every topic:

NoSQL exists because the web demanded horizontal scale and flexible shapes that a single relational server struggles with. It comes in four families: key-value (Redis, DynamoDB) for blazing key lookups; document (MongoDB) for flexible, nested, queryable records; wide-column (Cassandra, HBase) for massive write throughput organised by partition and clustering keys; and graph (Neo4j) for cheap relationship traversal β€” plus large-file/object stores (S3, HDFS) for huge static blobs and niche types (vector, object-oriented, multimodal). Remember that every store is addressable at a fixed unit (key / row / document), which sets its size limits. You choose between them β€” and SQL β€” by running a simple framework: access pattern β†’ consistency β†’ scale β†’ shape, often mixing several in one system (polyglot persistence). Applied to our messaging app, that means Cassandra for messages, Redis for presence, and a document store for inboxes β€” each store earning its place by the way its data is read and written.

Quick self-check

What's the core difference between a key-value store and a document store?

Both look up by a key, but a key-value store treats the value as an opaque blob (you can only fetch by key), while a document store understands the document's fields, so you can query and index by them. A document store is effectively a "smart key-value store."

In Cassandra, what do the partition key and clustering key each control?

The partition key decides which machine/node a row lives on (data with the same partition key is stored together). The clustering key decides the sort order of rows within a partition. You design the table around your query so reads hit one well-ordered partition.

Why is a graph database better than SQL for "friends of friends of friends"?

In SQL you'd JOIN a table to itself once per hop, getting exponentially slower. A graph database stores direct pointers between nodes (index-free adjacency), so you just walk the edges β€” cost depends on connections traversed, not on total database size.

What four questions does the decision framework ask, in order?

Access pattern (how you read/write), consistency (strong vs eventual), scale (how big/fast), and shape (relations, nesting). The access pattern drives the choice β€” not fashion.

Why does presence in a chat app suit Redis with a TTL?

Presence is tiny, changes constantly, is read very often, and is disposable (stale or lost data is harmless). In-memory Redis gives microsecond reads, and an auto-expiring key (TTL) means a user silently drops to offline when heartbeats stop β€” no explicit cleanup.

Why won't a key-value or document store work for "tweets per hashtag"?

A popular hashtag has 100M+ tweets (multiple GB). In key-value the value is a single opaque blob β€” too large, no pagination, and every new tweet rewrites the whole value. A single document hits the size cap and rewrites on every tweet; one-doc-per-tweet fixes size but still gives no cheap time-ordered pagination. A wide-column store (e.g. HBase) matches all the requirements: huge write throughput plus fast, paginated, time-ordered reads.

Why does a 16 MB MongoDB document rewrite entirely when you change one character?

Document stores are document-addressable: the smallest unit of read/write is the whole document. You can't touch a single nested attribute, so any change rewrites the entire document (and any read pulls the whole thing off disk). This is the same reason a 1-bit boolean costs a full byte β€” hardware is byte-addressable, never bit-addressable. Hence the size limits.

How fast is a single Redis server versus a SQL server, and why?

A single Redis server handles 100,000+ reads & writes/sec, versus roughly 100 writes and 1,000 reads/sec for a plain SQL server (a few thousand for well-tuned Postgres). Redis is that fast because it's in-memory (RAM) and does only simple key lookups. Watch out for the read-modify-write race condition on counters β€” use an atomic op like INCR instead.

What is "polyglot persistence," and how did our messaging app show it?

Using several different databases in one system, each for the job it's best at. The messaging app used Cassandra for messages, Redis for presence, and a document store for inboxes (plus relational for accounts) β€” each chosen via the same access-pattern framework.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives