Motivation
We converted the read-only detail endpoints of a healthcare claims platform to StreamOne<T> / StreamAggregate<T>. Frontends poll several of these detail endpoints (invoice details, upload feedback) while a user has the screen open. Streaming already removed the serialize cost, but every poll still ships the full body — invoices with hundreds of claim lines are easily 100KB+ — even when nothing changed.
Marten already stores exactly the change token we need, next to the data it streams:
- documents: the
mt_version column (and mt_last_modified);
- event-sourced aggregates: the stream version
FetchLatest resolves anyway.
So a conditional-GET fast path is one extra column in the select that is already happening.
Proposal
Teach the streaming result types conditional requests:
- On every response, set
ETag: "<version>" (document mt_version for StreamOne/StreamMany single sources, stream version for StreamAggregate; optionally Last-Modified from mt_last_modified).
- When the request carries
If-None-Match matching the current version, respond 304 Not Modified with an empty body.
For StreamOne the version can be read in the same query that fetches the document — a match means the body bytes are simply not written (one round-trip either way, zero bytes on the wire on a hit). For StreamAggregate, FetchLatest already knows the stream version before touching the snapshot, so a match can skip the snapshot/fold work entirely. For StreamMany a weak collection ETag (e.g. max(mt_version) + count over the filtered set) is a possible follow-up but has more caveats — fine to scope the first cut to the single-source types.
// Endpoint code does not change:
[WolverineGet("/api/v1/institution-invoice/{id}")]
public static StreamOne<InstitutionInvoice> Get(string id, IQuerySession session)
=> new(session.Query<InstitutionInvoice>().Where(x => x.Id == id));
// First request:
// 200 OK, ETag: "17", body: {...}
// Poll while unchanged:
// GET ... If-None-Match: "17" -> 304 Not Modified, no body
Opt-out (or opt-in) flag on the result type if someone's contract cannot tolerate the extra headers:
=> new(query) { EmitETag = false };
Why in Marten and not user land
The version travels in Marten's own metadata columns; only the streaming write path can read it in the same round-trip as the data and decide not to write the body. User code would need a separate metadata query per request, which erases most of the win.
Impact
For polled detail endpoints this beats the streaming conversion itself: a poll cycle where nothing changed goes from full-body every N seconds to a single indexed read returning zero body bytes. It composes with any HTTP cache in front (browser, CDN, YARP) for free.
Motivation
We converted the read-only detail endpoints of a healthcare claims platform to
StreamOne<T>/StreamAggregate<T>. Frontends poll several of these detail endpoints (invoice details, upload feedback) while a user has the screen open. Streaming already removed the serialize cost, but every poll still ships the full body — invoices with hundreds of claim lines are easily 100KB+ — even when nothing changed.Marten already stores exactly the change token we need, next to the data it streams:
mt_versioncolumn (andmt_last_modified);FetchLatestresolves anyway.So a conditional-GET fast path is one extra column in the select that is already happening.
Proposal
Teach the streaming result types conditional requests:
ETag: "<version>"(documentmt_versionforStreamOne/StreamManysingle sources, stream version forStreamAggregate; optionallyLast-Modifiedfrommt_last_modified).If-None-Matchmatching the current version, respond304 Not Modifiedwith an empty body.For
StreamOnethe version can be read in the same query that fetches the document — a match means the body bytes are simply not written (one round-trip either way, zero bytes on the wire on a hit). ForStreamAggregate,FetchLatestalready knows the stream version before touching the snapshot, so a match can skip the snapshot/fold work entirely. ForStreamManya weak collection ETag (e.g.max(mt_version)+ count over the filtered set) is a possible follow-up but has more caveats — fine to scope the first cut to the single-source types.Opt-out (or opt-in) flag on the result type if someone's contract cannot tolerate the extra headers:
Why in Marten and not user land
The version travels in Marten's own metadata columns; only the streaming write path can read it in the same round-trip as the data and decide not to write the body. User code would need a separate metadata query per request, which erases most of the win.
Impact
For polled detail endpoints this beats the streaming conversion itself: a poll cycle where nothing changed goes from full-body every N seconds to a single indexed read returning zero body bytes. It composes with any HTTP cache in front (browser, CDN, YARP) for free.