Polling an events table can skip an event. Nothing will tell you.
Your read model is wrong, the projection says it is caught up, and every event is in the table. What happened?
A typical pattern for event stores built on a SQL database is to have a projection remember the last event ID it processed. At regular intervals it asks the table for every event after that ID. That loop can skip an event that was committed, move its checkpoint past it, and report that it is caught up. Nothing in the table, the checkpoint or the lag will show that it happened.
This is not a bug in any one library. It happens because of how databases hand out IDs and when rows become visible, and it shows up wherever events are read this way. More than half of the event-sourcing libraries I analyzed either have reports of this problem in their trackers or they change how events are written to avoid it.
How the skip happens
An ID from a sequence or an auto-increment column is assigned when a row is inserted. The row becomes visible to other sessions when its transaction commits. Those are two different moments, and concurrent transactions do not necessarily commit in the order they took their IDs.
- Transaction A inserts an event and gets ID 100.
- Transaction B inserts an event and gets ID 101.
- B commits first. A is still running.
- The projection’s checkpoint is 99. It polls for IDs greater than 99, sees 101, applies it, and saves 101 as its checkpoint.
- A commits. Event 100 is now visible.
- The projection polls for IDs greater than 101. Event 100 is never read.
Nothing failed. Every query returned what the database had committed at that moment. The projection’s lag is zero, because its checkpoint matches the highest ID it could see.
Why the table cannot tell you
The obvious fix is to notice that 100 is missing and wait for it. The problem is that a missing ID is normal. A transaction that rolls back has still used its ID. A crash or failover can skip a run of sequence values. A failed batch can leave a hole that never fills.
So when a projection sees 99 and 101, it cannot tell from the table whether 100 is a transaction still in flight or a number that will never be used. Waiting forever stalls every projection behind a hole that might be permanent. Giving up too early skips an event that was about to commit. Every detector based on the IDs alone has to guess, and the guess is a trade-off between how long projections stall and how often they lose events.
Why event stores feel it more
The same race exists for any table read by polling, including query-based CDC on ordinary state tables. If a connector tracks a last-modified timestamp, it will pick up a row the next time it changes, and the destination is healed. If a connector only tracks new IDs, it can skip an insert and never recover, just like our event projection example.
An events table is always that second case. An event is written once and never changed, so if the projection skips it, the live loop will never read it again. A read model is a fold over events: a running total, a count, the latest status. A skipped event leaves the fold wrong until someone rebuilds it. For example, an order total can end up short one line item, which causes every later event to build on that wrong value.
The good news is that a rebuild fixes it. When the projection is replayed from the start, every transaction has long since committed, so the replay reads the event the live loop missed. The bad news is that users see numbers that change for no visible reason, which may cause them to lose faith in the system. Additionally, the rebuild destroys any evidence that could help track down the problem.
What the common fixes cost
The libraries I analyzed that poll use one of three main approaches to this problem. Each one deals with the race condition, but each one pays for it somewhere else.
1. Wait for the gap, but not forever. When the projection sees 101 but not 100, it stops and waits for 100 to show up. If 100 hasn’t appeared after a set amount of time, the projection assumes it never will and moves on. This keeps a permanent hole from stalling the projection forever. But if transaction A is just slow and commits after the timeout, event 100 is still skipped.
So how long should the timeout be? It has to be longer than the slowest append your system will ever make, measured from the moment it takes its ID to the moment it commits. Make it too short and you lose events. Make it too long and every projection waits that long whenever a transaction rolls back. Some libraries also check whether the transaction holding the missing ID is still running. That makes the guess better, but it is still a guess.
2. Only let one writer append at a time. If transaction B can’t take an ID until transaction A has committed, IDs commit in the order they were assigned, and the projection never sees 101 before 100. A table lock, an advisory lock, or an update to a shared row can all enforce this, as long as the writer holds it until it commits. The cost is write concurrency. Every append in the system waits in a single line, so one slow transaction holds up everyone behind it.
3. Ask Postgres which transactions are still running. Postgres can tell you the oldest transaction that is still in progress. If each event records the ID of the transaction that wrote it, a projection can read only events from transactions older than that one. In our example, the projection won’t read 101 while A is still running, so it never skips 100. This approach doesn’t skip events, but it has three costs. Events arrive in transaction-ID order, which isn’t always the order they committed. It only works on Postgres. And a single long-running transaction that has written something, anywhere on the server, holds every projection back until it finishes, even if it has nothing to do with events.
Reading the change stream instead
The fourth option is to not read the table at all. By default, Postgres’s logical decoding and MySQL’s binary log both deliver committed transactions in the order they committed. If a transaction is still running, then it is not in the stream yet, and a transaction that rolled back will never appear in the stream. The IDs can still have gaps, but they no longer matter, because the reader never sees an ID until its transaction is final.
In our example, B arrives first because it committed first, and A arrives when it commits. Nothing is skipped, and nothing has to wait.
This is how log-based CDC tools read databases, and it works well on event tables. It has its own costs. It needs replication privileges and some configuration on the source database. On Postgres, a replication slot retains log until its consumer confirms it, so a stopped reader can fill the source’s disk, which I covered in an earlier article. The stream can deliver some changes again after a crash, so the consumer has to handle repeats. And the reader still has to take an initial snapshot and switch to the stream without losing anything. In exchange, the ordering problem goes away, without a timeout or a lock.
How to check your own projections
If your projections poll by ID, these checks can show whether they have skipped anything or whether they could:
- Check that versions are contiguous. If the projection stores the last version it applied for each stream, every new event should be exactly one version higher. A jump means an event was skipped. This catches a skip as soon as the next event in that stream arrives, but it can’t see a skipped event that was the last one in its stream.
- Count per stream. Where the read model keeps a count of events or line items, compare it with a count from the events table. This catches the skipped last event that the version check misses.
- Rebuild into a shadow table and diff. Replay the projection into a separate table and compare it with the live one. Because a replay reads every committed event, any difference may be evidence of a skip.
- Look at your concurrency. The race needs concurrent appends. A store that serializes its appends will not see it, but an application with a connection pool is concurrent even if it runs as a single process. A store whose append load grew recently is where to look first.
- Check what your library does about gaps. If it uses a timeout, find out what the timeout is and what happens to an event that commits after it.
The checkpoint and the lag will both look healthy, so the evidence has to come from comparing the read model with the events it was built from.
Sources and scope
I analyzed the issue trackers, documentation and source of twenty event-sourcing libraries that store events in a SQL database, across ten languages, in October 2026: Marten, Axon Framework with JPA/JDBC storage, Prooph, Ecotone, Patchlevel, EventSauce, Spatie’s Laravel event sourcing, Rails Event Store, message-db, Sequent, Commanded, Emmett, Python’s eventsourcing, Akka Persistence JDBC, the postgresql-event-sourcing reference project, two Go libraries and three Rust libraries with SQL backends. “More than half” counts the libraries with tracker reports of projections skipping events or stalling on sequence gaps, plus those that serialize writers or use Postgres transaction IDs to avoid it. Reports describe particular versions and configurations, and several libraries have changed their gap handling since.
I checked the ordering behavior against the PostgreSQL documentation on sequences and logical decoding output, and the MySQL documentation on the binary log, and reproduced the skip on PostgreSQL 18 and MySQL 8.4.
Committed reads events tables in Postgres or MySQL the fourth way, through logical replication or the binary log, and builds the read models in your own database. It never has to guess whether event 100 is coming.