
The Wolverine community has occasionally discussed ways to make Wolverine better for serverless scenarios, especially for one shot lambdas. This latest improvement started with a conversation with a community member running Wolverine and Marten (plus a second service on EF Core) on Google Cloud Run with Pub/Sub, on a tight budget, who very reasonably wanted both the serverless billing model and the transactional outbox.
Wolverine was very much envisioned for long running processes, and for most of its life it was a poor fit for serverless platforms like Google Cloud Run, AWS Lambda, or Azure Functions because of how much it normally leans on background work for a lot of what it does: node registration and heartbeats, leader election, the durability agent that recovers the transactional inbox and outbox, polling for scheduled messages, and listeners pulling messages off of a broker.
None of that works well in a process that might only live as long as a single HTTP request, or that gets its CPU throttled the second the response goes out the door. And then on top of all that, Wolverine's runtime code generation meant that the first request to a cold instance paid for a Roslyn compilation, which is exactly the thing you cannot afford in a serverless function.
We've made huge improvements in the Wolverine 6.0 release that added AOT compliance, a mechanism for eliminating the expensive runtime compilation, and a lot of optimizations for Wolverine's cold start time. Wolverine 6.51 goes even farther by shipping our new DurabilityMode.ServerlessWithOutbox feature.
Let's dive into our new feature for incorporating the transactional outbox into serverless functions, then come back to cold start optimizations at the end.
ServerlessWithOutbox
The new mode is the same lean runtime as our older Serverless mode that turns off all background processing, but with the transactional outbox kept intact:
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Services.AddMarten("some connection string")
.IntegrateWithWolverine();
opts.Policies.AutoApplyTransactions();
// The same lean runtime as Serverless, but outgoing
// messages still go through the transactional outbox
opts.Durability.Mode = DurabilityMode.ServerlessWithOutbox;
// Optional. This is already 60 seconds under ServerlessWithOutbox
opts.Durability.OutboxStaleTime = 1.Minutes();
// Optional. Adds an index on the outgoing envelope table's
// timestamp column for the durability pass to use
opts.Durability.EnableOutgoingTimestampIndex = true;
}).StartAsync();Here's the quick comparison between the two modes, lifted straight from the documentation:
Serverless | ServerlessWithOutbox | |
|---|---|---|
| Node registration, leader election, background agents | Off | Off |
| Local queues | Removed | Removed |
| Sending endpoints | Forced to Inline | Made Durable where the transport can be |
| Transactional outbox | Off | On |
| Recovers messages that failed to send | No | Yes, through an externally triggered durability pass |
| Scheduled messages | Nothing releases them | Released by the durability pass |
| Extra moving parts | None | Something has to trigger the durability pass |
And here's what actually happens when a handler cascades a message in the new mode, because the sequencing is the whole point:
- The outgoing message is persisted to the outbox inside the very same database transaction as the rest of the handler's work, so the message and the business data either both commit or neither does. There's no node record in this mode, so the outbox row is marked as being owned by "any node."
- After the transaction commits, Wolverine sends every outgoing message right then, inside the same request, and waits for the sends to finish. The messages are grouped by destination so that each destination goes out in as few broker round trips as its transport allows, different destinations are sent concurrently, and each destination keeps its order. There's no background sending agent to hand things off to, which is the point. All the work finishes while your function still has CPU.
- Wolverine deletes the outbox rows for every message the broker actually accepted, in one batched call per message store rather than one round trip per message.
- If a send fails, that row stays in the outbox for the next durability pass to pick up. A failed send also restarts the row's stale clock, so it goes to the back of the line rather than being hammered on every pass. A message the broker is never going to accept, like an oversized SQS message without fragmentation turned on, is moved to dead letter storage instead of being retried forever.
Compare that to what Wolverine's normal durable outbox does in a long running process, where a background sending agent owns the outgoing rows, batches them up, sends them, and marks them off on its own schedule, with the durability agent on the leader node recovering anything orphaned by a crashed node. That design is great when you have nodes that live for days. It's actively harmful when your "node" is a request. The new mode throws all of that away and gets the same guarantee with far less machinery: no node table rows, no heartbeats, no agent assignment, no polling timers, and the only extra database work per request is the outbox insert inside a transaction you were already paying for and one batched delete after the sends succeed.
A couple of other things that quietly got better in this mode:
- You don't have to mark endpoints with
UseDurableOutbox()yourself. Every sending endpoint that can be durable is made durable, and transports that can't be durable fall back to inline. The durability mode wins over any per-endpoint setting, and Wolverine logs a warning naming each endpoint it overrode. - A message with no transaction behind it at all, like a plain
IMessageBus.PublishAsync()from a Wolverine.HTTP endpoint without transactional middleware, is still written to the outbox first and sent inline before the call returns, so a later pass can recover it. - With Rabbit MQ, Wolverine turns on publisher confirms for every channel and publishes to queues as mandatory. Because an outbox row is deleted as soon as its inline send returns, a publish only counts once the broker has confirmed it, and a queue that doesn't exist fails the send instead of silently dropping the message. That's one confirmed round trip per message, and it's the right trade.
- Messages that your own application handles, which would normally go to a local queue, aren't dropped. They're written to the durable inbox inside the handler's transaction and handled by the next durability pass. So they run with the outbox's atomicity, just not inside the originating request. Plain
Serverlessstill drops those, so useInvokeAsync()there.
This works today with the Marten, Polecat, Fisher, EF Core, and RavenDb integrations, and the durability pass that I'll get to next is supported by the PostgreSQL, SQL Server, MySQL, Oracle, SQLite, RavenDb, and Cosmos DB message stores. That includes multi-tenanted message databases, where the pass works its way through every tenant database. The compliance test matrix for this was not small.
The Durability Pass
Since there's no background durability agent in this mode, something outside of your function has to periodically tell Wolverine to catch up. We call that a durability pass, and one pass:
- Sends the stale outbox messages whose inline send failed
- Releases scheduled messages that are due, including scheduled retries from your error handling policies
- Recovers incoming messages that were persisted to the inbox but never handled
- Deletes expired handled envelopes, dead letters, and deduplication records
A pass is bounded by a maximum duration (30 seconds by default) and a batch size, so it fits comfortably inside a single function invocation. Each message store takes a non-blocking lock before doing any work, so if two passes overlap, the second one just skips the stores the first one is already working on instead of waiting or double sending. And a pass will only try to recover an outbox row once it's older than OutboxStaleTime, which defaults to 60 seconds in this mode. That keeps a pass from racing a request that is still in the middle of sending that very message. 60 seconds happens to line up with Cloud Scheduler's one minute minimum and with typical broker SDK publish timeouts, and if an inline send somehow hangs longer than that, you get a duplicate, which Wolverine's at-least-once delivery already allows for.
There are three ways to trigger a pass, and you'll pick based on what your cloud of choice makes easy.
Directly in code, from your own timer triggered function or job:
// Run one bounded pass of durability work across every message store
var report = await host.RunDurabilityPassAsync(maxDuration: 30.Seconds());
// If the pass ran out of time or hit the batch size, it tells you
// so and you can just go around again
while (report.HasMoreWork && !report.TimedOut)
{
report = await host.RunDurabilityPassAsync(maxDuration: 30.Seconds());
}
Console.WriteLine($"Sent {report.OutgoingSent} outgoing messages, released {report.ScheduledReleased} scheduled messages");Over HTTP with Wolverine.HTTP, which is what I'd expect most people to do with Cloud Scheduler, EventBridge Scheduler, or a timer triggered function:
// The pass sends messages and deletes rows, so mapping it takes an authorization
// decision: a policy, or AllowAnonymous() when Cloud Run IAM or a function key
// already protects the application
app.MapWolverineDurabilityEndpoints(opts => opts.RequireAuthorization("scheduler"));That maps POST /_wolverine/durability/pass, and the response is a JSON report of what the pass did with a 200 if every step succeeded or a 500 if anything failed so that your scheduler records the failure. Note that this endpoint sends messages and deletes rows from your database, so MapWolverineDurabilityEndpoints() makes you say how it's protected and throws if you don't. Either name an authorization policy or call AllowAnonymous() explicitly when something in front of the application like Cloud Run IAM already decides who gets in. I'd rather make you type that than have you find out the hard way.
From the command line, which is a good fit for a Cloud Run Job or a Kubernetes CronJob:
dotnet run -- storage flush --max-duration 30 --batch-size 500The trade here is explicit, and I want to be upfront about it: the latency for recovering a failed send or releasing a scheduled message is however often you trigger the pass. For the happy path that's irrelevant because messages go out inline during the request. For the unhappy path, you're trading a minute or so of latency for not paying for an always-on process. That's the deal our Cloud Run user wanted, and I think it's the deal most people reaching for serverless want.
Receiving Messages by Push
Neither serverless mode starts any listeners for you, because a serverless function isn't going to sit around polling a broker. What serverless platforms do instead is push messages to you over HTTP, with Pub/Sub push subscriptions, SNS or EventBridge to an HTTP target, Azure Event Grid, and so on. So Wolverine.HTTP now has a push receiver that turns an incoming HTTP request into a Wolverine envelope through a pluggable mapper and runs your handler inline:
// The mapper is the transport's: it turns the request the broker pushes into a Wolverine envelope.
// Mapping the receiver takes an authorization decision, here a policy that validates the push
// subscription's OIDC token
app.MapWolverinePushReceiver("/_wolverine/push/orders", new FakePubsubPushMapper(),
opts => opts.RequireAuthorization("pubsub-push"));For each request the receiver maps the body to one or more envelopes, runs the handler inline with all of your normal error handling policies, sends every outgoing message before writing the response, and then uses the status code to tell the broker what happened. A message that was handled, discarded, or moved to Wolverine's dead letter storage gets a 200 so the broker acks it. If your error policies asked for a retry, or an exception escaped them altogether, the receiver answers with a 500 so the broker redelivers with its own backoff. Since push delivery is at-least-once, you can also set DeduplicateWithInbox to have the receiver claim the message id in the durable inbox before the handler runs, so a redelivery that shows up while the first delivery is still in flight gets a retry status instead of running your handler a second time.
For Google Pub/Sub specifically, the separate WolverineFx.Pubsub.Http package adds a mapper for push subscriptions, so that an application pulling from Pub/Sub the normal way doesn't take on an ASP.NET Core dependency it doesn't need:
// Every push subscription can share one route: each pushed message is read by the Pub/Sub endpoint
// whose subscription the push request names. Cloud Run IAM already protects the service here
app.MapPubsubPushReceiver("/pubsub/push", opts => opts.AllowAnonymous());Each pushed message is read by the Pub/Sub endpoint whose subscription the push request names, so any custom envelope mapper or interoperability settings you've configured for that endpoint apply to pushed messages exactly the same way they do to pulled ones. Wolverine does not create the push subscriptions for you though. Those need the public URL of your function, an OIDC audience, an ack deadline, and ideally a dead letter policy, and that's a job for Terraform or gcloud, not for AutoProvision().
What Still Doesn't Work
This isn't so much a list of things that don't work as a list of things that still need a long running Wolverine process, because they fundamentally depend on a process that stays up. Just so you know going in:
- Listeners on external brokers. Use push delivery as above.
- Recurring scheduled messages. Use your cloud provider's scheduler to call an HTTP endpoint instead.
- Anything that depends on leader election or agent assignment, like the Marten async daemon in distributed mode.
- Brokered request/reply.
InvokeAsync<T>()to a remote endpoint needs a listener to bring the reply back, so it now fails fast instead of running the remote handler and then waiting out the whole timeout. Request/reply over Wolverine's own HTTP transport is fine, because the reply comes back in the same exchange.
Cold Starts, Roslyn, and AOT
Now for the other half of the story, which is arguably the more important one for serverless. Wolverine's whole model is built on generating the handler pipeline as C# code at startup so that there's no reflection or dictionary-of-delegates overhead at runtime. For most of Wolverine's life that generated code was compiled in process with Roslyn on the first use of each handler, which meant that every Wolverine deployment carried the Roslyn compiler around and paid a noticeable cold start penalty. For a long running service that was an annoyance you noticed once a deploy. For a serverless function that might be cold several times a minute, it was disqualifying, and I'll own that it's why I used to steer people away from Wolverine for serverless altogether.
Wolverine 6 broke that dependency completely. The core WolverineFx package no longer references the runtime compiler at all. If you want the old dynamic behavior during development, you reference the WolverineFx.RuntimeCompilation package and it registers itself. For production, you pre-generate all the handler code in your build with:
dotnet run -- codegen writeand run with TypeLoadMode.Static, where Wolverine just loads the pre-generated types by name and never compiles anything. A Static mode deployment doesn't carry Roslyn, starts faster, and fails loudly at startup if a handler is missing its pre-generated code rather than silently falling back to compiling it. The pattern I'd recommend is to make the runtime compilation package reference conditional on a Debug build so a Release publish physically doesn't contain it, and the CqrsMinimalApi sample has a complete, tested version of that with a multi-stage Dockerfile and a script that asserts the production build is Roslyn-free. Static mode also emits a generated handler registry, so Wolverine skips the conventional assembly scan at startup entirely. For a serverless function, every one of those milliseconds matters.
Once you're in Static mode with pre-generated code, the next step is Native AOT. Wolverine 6 introduced first class support for dotnet publish /p:PublishAot=true, with a smoke project in CI that promotes the trimmer and AOT warnings to errors so that the AOT-clean surface can't regress. I'll be honest that this is still in progress. The runtime path in Static mode avoids the dynamic code, but there are still Wolverine APIs outside the annotated subset that will emit IL warnings under AOT publishing today, and the docs are explicit about which surfaces are intentionally never going to be AOT clean. The direction is set though, and the combination of ServerlessWithOutbox for correctness, Static mode for cold starts, and AOT for the smallest, fastest possible binary is the serverless story I'd wanted Wolverine to have for years.
Wrapping Up
Everything in this post shipped in Wolverine 6.51, and the design discussion is all public in the epic if you want to see how the open questions got settled. My thanks to the community member who brought the problem and tested the result against a real Cloud Run deployment. If you're running Wolverine on a serverless platform, or you'd been avoiding it there for the reasons above, I'd love to hear how this works for you in the Critter Stack Discord.

