Your Read Model Doesn't Always Need a Database¶
Sooner or later, every team building an event-sourced system runs into the same question: which database should we use for the read model? The textbook answer is reassuringly pragmatic. It depends. Pick the tool that fits the access pattern. Relational if you need joins and ad-hoc queries, document-oriented if your data is hierarchical, key-value if you mostly look things up by ID, a graph database for relationships, a spatial database for geodata, and so on.
That advice is sound, and yet it quietly assumes something that nobody ever questions: that there has to be a database in the first place. There's one option that almost never makes it onto that list, and it's the one that turns the whole question on its head. For a large class of read models, you don't need a database at all. You can keep the entire read model in memory and serve it straight from there.
The Question Everyone Asks¶
The polyglot-persistence answer is good advice, but it answers a narrower question than the one people think they're asking. "Which database fits this access pattern?" is a useful question once you've established that you need a database. It says nothing about whether you need one.
In a system backed by a relational database as the single source of truth, the question makes sense, because that database is your data. There's nothing to fall back on. If you lose it, you lose everything, so it has to be durable, transactional, and carefully chosen.
An event-sourced system is different in a way that changes the entire calculation. The events are the source of truth. Everything else is derived from them. And a read model is exactly that: something derived. That single property is what opens the door we're about to walk through.
A Read Model Is Not Your Source of Truth¶
It's worth being precise about what a read model actually is, because the word "database" carries a lot of baggage. A read model in an event-sourced system is a projection. It's the result of folding a stream of events into whatever shape your queries happen to need. It holds no information that the event store doesn't already have.
That makes a read model disposable by design. You can throw it away and rebuild it from the events at any time. You can have ten different read models of the same events, each shaped for a different screen, and none of them is more "correct" than the others. If this framing is new to you, our post on Your Aggregate is Not a Table makes the same point from the write side, and CQRS Without the Complexity explains why separating the read side from the write side is what makes all of this possible in the first place.
Once you internalize that a read model is derived and disposable, the requirements you'd normally put on a database start to look negotiable. Durability stops being a hard constraint, because the durable copy lives somewhere else. And that's precisely the point where keeping the read model in memory becomes a reasonable engineering choice rather than a reckless one.
"But RAM Is Volatile"¶
This is the first objection, and it's a fair one. Memory doesn't survive a restart. If your process goes down, the in-memory read model is gone.
The thing is, that's fine, because the read model was never the thing you couldn't afford to lose. When the process starts again, it replays the events from the event store and rebuilds the read model from scratch. For most systems this takes seconds, maybe a little longer for large histories. The read model is briefly unavailable while it catches up, and then it's back, identical to what it was before. A rebuild is not data recovery. It's just the read model doing the only thing it ever does.
In practice you also don't run a single instance. You run several, behind a load balancer, each with its own copy of the read model in memory. They don't all restart at the same moment, so from the outside the read model stays available even while individual instances cycle. And if an instance reconnects after a disconnect, EventSourcingDB lets it resume from the ID of the last event it saw, so it doesn't have to start from zero every time. The volatility of memory turns out to be a non-issue once the durable truth lives in the event store.
"But It Won't Fit in Memory"¶
The second objection is about size, and this one deserves a clear-eyed look rather than a reflexive dismissal. Some read models genuinely are too large to hold in memory, and for those you should absolutely use a database.
But take a moment to estimate before you assume. A catalog of every book in a mid-sized library, a list of open orders, the state behind a dashboard, the projection that backs a single screen: these are often tens of thousands of small records, not billions. A few hundred thousand objects of a few hundred bytes each is a handful of megabytes. A modern server has tens or hundreds of gigabytes of RAM. The read model you're worried about is frequently three or four orders of magnitude smaller than the machine it runs on.
So the rule isn't "always keep it in memory." The rule is: start by checking whether it fits, and only reach for a database when it actually doesn't. When the read model truly is too large, or needs to outlive any single process, or has to be queried in ways you can't predict, a database is the right answer. The mistake is reaching for one by default, before you've asked the question at all.
View and Query Are Two Different Things¶
Before we write any code, it's worth separating two concepts that often get blurred together, because keeping them apart is what makes the in-memory approach clean.
The View is the data structure in memory. It's the projected state, the result of folding the events into the shape we want. In our example it's going to be a catalog of books, held in a plain Map. The View knows nothing about HTTP, callers, or formats. It's just state.
A Query is a question someone asks against the View. "Which books are currently available?" is a query. "Give me the book with this ID" is a query. In a typical service, a query arrives over HTTP, gets answered from the View, and a response goes back. The query never touches the event store and never touches a database, because the answer is already sitting in memory. With that distinction in place, the code almost writes itself.
Building the View¶
Let's make this concrete with EventSourcingDB and its TypeScript SDK. Picture a library that records what happens to its books as events: a book enters the collection, it gets borrowed, and eventually it's returned. In an event-sourced system those become BookAcquired, BookBorrowed, and BookReturned events, and our read model folds them into a catalog that always knows which books are currently available.
Install the SDK with npm install eventsourcingdb, then build the View by observing every event recursively from the root subject / and folding it into a Map:
import { Client } from 'eventsourcingdb';
const client = new Client(new URL('http://localhost:3000'), 'secret');
type BookView = {
title: string;
author: string;
isbn: string;
isAvailable: boolean;
borrowedBy?: string;
borrowedUntil?: string;
};
// The View: the entire read model, kept in memory.
const catalog = new Map<string, BookView>();
const buildView = async (): Promise<void> => {
for await (const event of client.observeEvents('/', { recursive: true })) {
switch (event.type) {
case 'io.eventsourcingdb.library.book-acquired': {
catalog.set(event.subject, {
title: event.data.title as string,
author: event.data.author as string,
isbn: event.data.isbn as string,
isAvailable: true,
});
break;
}
case 'io.eventsourcingdb.library.book-borrowed': {
const book = catalog.get(event.subject);
if (book === undefined) {
break;
}
book.isAvailable = false;
book.borrowedBy = event.data.readerId as string;
book.borrowedUntil = event.data.borrowedUntil as string;
break;
}
case 'io.eventsourcingdb.library.book-returned': {
const book = catalog.get(event.subject);
if (book === undefined) {
break;
}
book.isAvailable = true;
book.borrowedBy = undefined;
book.borrowedUntil = undefined;
break;
}
}
}
};
That's the whole projection. observeEvents first replays every event that already exists, then keeps the connection open and streams new ones as they're written. The same for await loop that builds the View on startup also keeps it up to date forever, with no separate code path for "catch up" versus "stay current." The BookAcquired event creates an entry keyed by the book's subject, BookBorrowed flips it to unavailable, and BookReturned flips it back. There's no schema to define, no table to migrate, no ORM to configure.
Serving the Query¶
Now the query side, which is almost anticlimactic. We expose a minimal HTTP server using nothing but Node's built-in module, and every endpoint answers its question by reading from the Map:
import { createServer } from 'node:http';
// A Query is a question answered from the View, never from a database.
const server = createServer((request, response) => {
if (request.url === '/available-books') {
const availableBooks = [...catalog.values()].filter(
(book) => book.isAvailable,
);
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(availableBooks));
return;
}
response.writeHead(404);
response.end();
});
// Start rebuilding the View, then serve queries from memory.
buildView();
server.listen(8080);
Look at what the /available-books endpoint actually does. It filters an in-memory Map and serializes the result. There is no query planner, no index, no network round-trip to a database, no connection pool. The answer was already computed and already in memory before the request ever arrived. This is about as fast as a read can get, because there's almost nothing left to do at request time.
Notice also that buildView is started but not awaited. The View rebuilds in the background while the server is already accepting connections. For the first moment after startup the catalog is still filling up, and depending on your requirements you might want to delay server.listen until an initial replay has completed. That's a small refinement, not a structural problem.
One thing worth spelling out: we used TypeScript here, but nothing about this approach depends on it. Observe recursively, fold the events into a structure in memory, answer queries from it. That same pattern works in every language we provide a client SDK for, whether your stack runs on .NET, Go, Java, Python, Rust, or something else. The language is an implementation detail; the idea is what carries over.
What You Get in Return¶
The speed is the obvious benefit, but it's not the one we'd put first. The one that changes how you work day to day is how trivially the read model adapts to change.
Suppose product asks for a new field, or a different grouping, or a completely new view of the same data. With a database-backed read model, that's a schema change, a migration, possibly a backfill, and a careful deployment. With an in-memory View, you change the projection code, restart the process, and the new shape is rebuilt from the events in seconds. There is no migration, because there is no schema to migrate. There are no stale rows, because there are no rows. The read model is just code plus the event log, and both of those you already control.
This is Event Sourcing delivering on its central promise. The events are the durable, authoritative record. Everything you show to users is a projection you're free to reshape, throw away, and rebuild whenever the requirements move. Holding the projection in memory is what makes that freedom feel immediate rather than theoretical.
In-Memory Is a Design Choice¶
None of this makes in-memory read models a silver bullet, and treating them as one would be its own kind of mistake. A read model that genuinely doesn't fit in RAM needs a database. A read model that has to be shared across many processes, or queried in ways you can't anticipate, or kept available without any rebuild window, will often be better off in a database too. Those are real constraints, and when you hit them, use the right tool.
The point is narrower and more useful than "always do this." It's that "keep it in memory" belongs on the list of options you consider, right next to relational, document, and key-value, and for a surprising number of read models it's the simplest and fastest thing that works. The next time someone asks which database to use for the read model, it's worth asking first whether this one needs a database at all.
If you'd like to try this yourself, the guide on Observing Events walks through everything the projection above relies on, and it's the fastest way to get your first in-memory read model running against real event data.
And if you're weighing this approach for a system you're building and want a second pair of eyes on the trade-offs, we'd genuinely like to hear about it. Write to us at hello@thenativeweb.io, we're always happy to talk through how this plays out in practice.