# The message that blocks the queue

> One event a handler can't read stops everything behind it, at three in the morning. Stop and wait, or set it aside and carry on: when each is right, and how a parked event comes back once the bug is fixed.

*A post after the series [CQRS with effect-cqrs](https://effect-cqrs.dambox.ch/blog/), on its lending library. November 2026.*

![A line of books waits on a conveyor at a spine-labelling machine, while a librarian lifts the one book it jammed on onto a side cart](https://effect-cqrs.dambox.ch/_astro/the-message-that-blocks-the-queue.C0Q4wEgk_1GNdbn.webp)

Every queue consumer meets it eventually: the *poison message*. One message the code can’t handle, because of a bug, or data nobody expected. The consumer fails, retries, fails again, and everything behind it waits. In the morning the dashboard shows a lag of six hours and one error, repeated forty thousand times.

View models are consumers of the log, so they meet it too. This post shows what effect-cqrs does by default, why, and the other choice. Every snippet runs in [`examples/blog/blocked-queue.test.ts`](https://github.com/davidnussio/effect-cqrs/blob/main/examples/blog/blocked-queue.test.ts).

## Shelf marks

The library prints a shelf mark on every spine: the first three letters of the author’s surname. A view model works them out as books are added, and it has a bug, an assumption every developer has made about names:

examples/blog/blocked-queue.ts

```ts
/** The shelf mark printed on each book's spine: the first letters of the author's surname. */
export class ShelfMarks extends Context.Service<ShelfMarks, Ref.Ref<ReadonlyMap<string, string>>>()(
  "library/ShelfMarks"
) {
  static readonly layer = Layer.effect(ShelfMarks, Ref.make<ReadonlyMap<string, string>>(new Map()))
}


// The bug: an author is assumed to have a first name and a surname.
const surnameOf = (author: string) => author.split(" ")[1]!


const shelfMarks = (surname: (author: string) => string, onFailure?: ViewModel.FailurePolicy) =>
  ViewModel.make({
    name: "shelf-marks",
    aggregates: [Book],
    dispose: ShelfMarks.use((marks) => Ref.set(marks, new Map())),
    onFailure,
    handlers: {
      BookAdded: ({ author }, { aggregateId }) =>
        ShelfMarks.use((marks) =>
          Ref.update(marks, (all) => new Map(all).set(aggregateId, surname(author).slice(0, 3).toUpperCase()))
        )
    }
  })


export const ShelfMarksView = shelfMarks(surnameOf)
```

It works for Frank Herbert and William Gibson. Then a librarian adds *The Odyssey*:

examples/blog/blocked-queue.ts

```ts
yield* books.commands.add("dune", { title: "Dune", author: "Frank Herbert" })
yield* books.commands.add("odyssey", { title: "The Odyssey", author: "Homer" })
yield* books.commands.add("neuromancer", { title: "Neuromancer", author: "William Gibson" })
```

Homer has one name. `split(" ")[1]` is `undefined`, and `.slice` throws.

## Stop and wait

By default, a view model that fails on an event stops there and tries again: after 100 milliseconds, then twice as long each time, up to every 30 seconds. It never skips the event and never handles a later one first:

examples/blog/blocked-queue.ts

```ts
const status = yield* waitFor(statusOf("shelf-marks"), (status) => Option.isSome(status?.lastError ?? Option.none()))
// { name: "shelf-marks", position: 1, lastError: Some(ViewModelError { position: 2, cause: TypeError }) }


const marks = yield* ShelfMarks.use(Ref.get)
// Map { "dune" => "HER" }: nothing for Homer, and nothing for Gibson either
const catalog = yield* Catalog.use(Ref.get)
// all three books: the catalog has no bug, and doesn't wait for the shelf marks
```

The shelf marks are stuck at position 1, after *Dune*. *Neuromancer* has no shelf mark either, although nothing is wrong with it: it is behind Homer. The catalog, a different view model over the same events, has all three books. Each view model reads the log on its own, from its own checkpoint, so one that is stuck holds up no one else.

That’s the default because it is the safe one. A view model that is stalled is *visibly* wrong: `status` shows where it stopped and why, a health check can alert on it, and the moment the bug is fixed and deployed it carries on from Homer, in order, and is right again. A view model that skipped an event would be wrong in a way nothing shows, for as long as it lives. For an account balance, a stock level, or anything where every event changes a total, missing one is worse than being late.

Note

`caughtUp` doesn’t return while a view model is stalled, since it waits for every view model to reach a position. Put a timeout on it where a request is waiting.

## Or set it aside

Shelf marks are not a balance. A book without a label for a day is a small problem. Every book after it without a label is a big one. For a read model like this, a gap is better than a stop, and `park` says so:

examples/blog/blocked-queue.ts

```ts
export const ParkingShelfMarksView = shelfMarks(surnameOf, ViewModel.park({ attempts: 3 }))
```

After three failed attempts, the view model records the event in `ParkedEvents` and moves on:

examples/blog/blocked-queue.ts

```ts
yield* waitFor(viewModels.parked("shelf-marks"), (parked) => parked.length > 0)
yield* viewModels.caughtUp()


const marks = yield* ShelfMarks.use(Ref.get)
// Map { "dune" => "HER", "neuromancer" => "GIB" }
const parked = yield* viewModels.parked("shelf-marks")
// [{ viewModel: "shelf-marks", position: 2, eventId: "book/odyssey@1", error: "…TypeError…", attempts: 3, … }]
```

Gibson has his shelf mark. Homer is parked: the event’s position and id, the error, and how many times it was tried. That list is the to-do list for the morning, and it’s in one place, not spread over forty thousand log lines.

## Bring it back

The fix takes the last word as the surname, so a single name is all surname. Deploy it, and hand the parked event to the fixed handler:

examples/blog/blocked-queue.ts

```ts
// The fix: the surname is the last word, and a single name is all surname.
const lastWord = (author: string) => author.split(" ").at(-1) ?? author


export const FixedShelfMarksView = shelfMarks(lastWord, ViewModel.park({ attempts: 3 }))
```

examples/blog/blocked-queue.ts

```ts
const result = yield* viewModels.retryParked({ viewModel: "shelf-marks" })
// { succeeded: [2], failed: [] }


const marks = yield* ShelfMarks.use(Ref.get)
// Map { "dune" => "HER", "neuromancer" => "GIB", "odyssey" => "HOM" }
```

`retryParked` handles the parked events again, in order of position. Those that succeed leave the list. Those that fail again stay, with the new error, so a fix that didn’t work is as visible as the bug was.

## What parking costs

* **Order.** Homer’s shelf mark was written after Gibson’s. For shelf marks that doesn’t matter: each book has its own. For a read model where events of the same thing must be applied in order, a later event can be handled before the parked one, and the result can be wrong in a way the retry won’t undo. Park only where events don’t depend on each other.
* **Replays skip parked events** until they are retried, since the handler would fail on them again.
* **Someone has to look.** A parked event is a bug report the system filed for you. Keep `ParkedEvents` in the database with `ParkedEvents.layerSql()` rather than in memory, watch the `parked` count in `status`, and alert on it.

## Rules of thumb

* **Stall by default.** A view model that is visibly behind is better than one that is silently wrong.
* **Park where a gap is better than a stop:** labels, search indexes, notifications, statistics. Never where events add up to a total.
* **Read models are independent.** One stuck view model holds up nothing but itself, which is a reason to split read models by purpose rather than build one that does everything.
* **Fix, deploy, retry.** The parked event is still in the log, unchanged, waiting for a handler that can read it.

## Next

The [view models guide](https://effect-cqrs.dambox.ch/guides/view-models/#failing-handlers) covers failures, parking and retries in full, and [Fix the code, not the data](https://effect-cqrs.dambox.ch/blog/fix-the-code-not-the-data/) covers the bug that doesn’t fail: a handler that writes the wrong thing.
