What you'll learn
Quick Answer
CQRS, Command Query Responsibility Segregation, means separating the model that handles writes (commands) from the model that handles reads (queries), instead of using one model for both. The write model stays normalized and enforces business rules; the read model is a denormalized projection optimized for fast queries, updated asynchronously after writes happen. That gap between a write landing and the read model reflecting it is real and measurable — the demo below shows a dashboard query returning stale data for hundreds of milliseconds after an order ships.
One Model Trying to Do Two Jobs
A typical CRUD setup uses one model for everything: the same Order class or table handles validating a new order, updating its status, and rendering a dashboard row. That works until the read side and the write side start wanting different things — the write side wants strict validation and normalized tables to avoid duplicate data; the dashboard wants one denormalized query that doesn't join five tables under load.
CQRS is the decision to stop forcing one model to serve both. Commands (writes) go through a model built for correctness. Queries (reads) go through a separate model built for speed, kept in sync by something that watches the writes and updates the read side afterward.
"Afterward" is the important word, and it's not a minor detail, it's the whole tradeoff. The demo below makes that gap visible in real milliseconds.
Retrofitting the split onto an application that already conflates the two is usually more painful than designing for it from the start, which is why the decision is worth making deliberately, before the read and write sides are tangled together in one model, rather than backing into it later under load.
Write Model and Read Model, Separately
class WriteModel {
constructor() { this.orders = new Map(); }
createOrder(id, item, qty) {
this.orders.set(id, { id, item, qty, status: 'placed' });
return this.orders.get(id);
}
shipOrder(id) {
const order = this.orders.get(id);
order.status = 'shipped';
return order;
}
}
class ReadModel {
constructor() { this.dashboard = new Map(); }
project(order) {
this.dashboard.set(order.id, { item: order.item, status: order.status });
}
getDashboardRow(id) { return this.dashboard.get(id); }
}
WriteModel is the source of truth, it's what createOrder and shipOrder actually mutate. ReadModel is a completely separate, denormalized shape built only for fast lookups, and nothing writes to it directly. Something else, a projector, has to move data from one to the other, and that move doesn't happen instantly.
The Map-based storage here is just for a runnable demo — a real WriteModel would sit on a relational database enforcing constraints, and a real ReadModel would sit on whatever store makes its specific query fast: a cache, a search index, or a denormalized table, chosen independently of whatever the write side uses underneath.
Watching the Lag Happen
The projector below simulates real asynchronous delivery with setTimeout instead of pretending the read model updates instantly:
function projectAsync(order, delayMs) {
setTimeout(() => {
readModel.project(order);
}, delayMs);
}
const order = writeModel.createOrder('ord_100', 'Java Bootcamp', 1);
projectAsync({ ...order }, 300);
console.log(readModel.getDashboardRow('ord_100')); // queried immediately
Actual output, in the order it happened:
[WRITE] order ord_100 created: Java Bootcamp x1
[READ] querying dashboard immediately after write: undefined
[WRITE] order ord_100 marked shipped
[READ] querying dashboard right after ship command: undefined
[READ-PROJECTOR] dashboard updated -> status=placed (after 300ms lag)
[READ] querying dashboard at t=400ms: { item: 'Java Bootcamp', status: 'placed' }
[READ-PROJECTOR] dashboard updated -> status=shipped (after 600ms lag)
[READ] querying dashboard at t=700ms: { item: 'Java Bootcamp', status: 'shipped' }
The write happens, and the dashboard genuinely does not know about it yet, the query returns undefined, not "placed". Only after the projector's delay elapses does the read side catch up, and by then the write side has already moved on to "shipped".
Why Accept Eventual Consistency
In exchange for that lag, the read model can be shaped and stored however queries need it: a single denormalized document per dashboard row, cached aggressively, scaled with read replicas, or even built by merging multiple write models into one view. None of that shaping constrains or slows the write side, because they're no longer the same table.
You can also build several read models from the same writes, a dashboard view, a search index, and an analytics rollup, each projected independently and optimized for its own query pattern, without touching the write model or each other. That flexibility is the actual payoff; the lag in the demo above is the price for it.
This mirrors the tradeoff distributed systems make explicitly under the CAP theorem: choosing to serve reads quickly from a projection means accepting that the projection can be momentarily behind the source of truth, rather than blocking every read until the write side confirms full consistency.
When CQRS Is Overkill
Most CRUD applications don't need this. A blog with a few thousand posts, an internal admin tool, a small SaaS dashboard with normal traffic — one model handling both reads and writes is simpler to build, simpler to reason about, and has no eventual-consistency gap to explain to confused users.
Reach for CQRS when read and write patterns have genuinely diverged: heavy read traffic on views that don't map cleanly to your write-side tables, write-side business rules complex enough that mixing them with read concerns bloats the model, or when you're already storing an event log and need a fast queryable projection of it. Adding the split before you have that pressure just adds a projector to build, a lag to explain, and a second model to keep in sync. The added complexity shows up as more moving parts to operate and monitor, not just more code to write, which is the real cost worth weighing before adopting the pattern.
