Azure Functions has been quietly moving away from the in-process model for a few years now. Microsoft confirmed back in August 2023 that .NET 8 would be the last LTS release to get in-process support, which means any new binding extension you build from here on has to target the isolated worker model. Tomasz Peczek covers this shift on his blog, tpeczek.com, and walks through what actually changes when extensibility work moves from the host process into a separate worker, and what stays exactly the same.
If you have already written a custom Azure Functions extension for the in-process model, most of what you know still applies. The isolated worker model does not throw that knowledge away, it builds another layer on top of it. That layer brings its own quirks around serialization, converters and startup hooks, and those are worth understanding properly before you start building or porting an extension.
How the Host and the Isolated Worker Actually Talk
Even in the days of Azure Functions v2, when the host process and the language worker process were split apart, .NET based function apps kept running inside the host process itself. That gave good performance since there was no inter process communication, and it let .NET functions use the full capabilities of the host. But it also meant every dependency conflict between the app and the host became a headache, and every new .NET version had to wait for the host to catch up. Azure Functions .NET Worker, the isolated model, exists to fix exactly this coupling.
Under the hood the isolated worker is essentially an ASP.NET Core application. It receives inputs from the host and sends outputs back to it over gRPC rather than plain HTTP, since gRPC handles this kind of structured request and response traffic more efficiently than the primitives used by custom handlers. Developers interact with this through a new binding model built around attributes shipped in the *.Azure.Functions.Worker.Extensions.* packages. The actual binding work, though, still happens on the host side.

Worker Extension Packages Are a Bridge, Not a Rewrite
This is the part that trips people up. The worker extension packages you reference in an isolated function app do not contain the actual binding logic. They are a thin bridge to the corresponding in-process extension package. So if you want to build a new extension, or understand how an existing one behaves, you still start with an in-process extension. The worker package maps to it through an assembly level attribute that names the in-process package and the version to pull in.
[assembly: ExtensionInformation("RethinkDb.Azure.WebJobs.Extensions", "0.6.0")]
During build, the Azure Functions tooling reads this attribute and uses NuGet to install the matching in-process package automatically. You never reference it directly. That is convenient, but it comes with real drawbacks: your worker extension is now tightly coupled to a specific in-process version, and debugging across the two processes gets harder because one of the assemblies involved is not sitting in your project references at all.

Binding Attributes Are What Keep the Two Processes in Sync
In-process extensions work with two kinds of attributes: one for regular bindings and one for triggers. The isolated worker model splits this into three, because input and output bindings are no longer the same attribute type. You get one for input binding, one for output binding, and one for trigger binding.
public class RethinkDbInputAttribute : InputBindingAttribute
{
...
}
public sealed class RethinkDbOutputAttribute : OutputBindingAttribute
{
...
}
public sealed class RethinkDbTriggerAttribute : TriggerBindingAttribute
{
...
}
These attributes do double duty. Developers use them directly to decorate their functions and pass settings, but the worker also serializes them as metadata and sends that metadata to the host. On the host side, they get deserialized back into the matching in-process attribute, input and output attributes becoming the in-process binding attribute, and the trigger attribute becoming the in-process trigger attribute. The property names have to line up on both sides for this round trip to work, so renaming a property on one side without the other is a common source of bugs that only shows up at runtime.
Just decorating your functions with these attributes is enough to get POCO support working end to end, since the host and worker handle serialization and the gRPC transfer for you. The moment you want to bind to something richer than a POCO, though, you need a different mechanism.
Going Beyond POCOs With Input Converters
In-process extensions commonly let you bind directly to SDK types, for example a CosmosClient for Azure Cosmos DB. That kind of binding data cannot be serialized and shipped over gRPC, so isolated worker extensions cannot offer it out of the box. Input converters close that gap. A converter is a class that implements IInputConverter, and that interface has a single method that returns a conversion result. There are three possible outcomes for a conversion attempt.
- Unhandled, meaning the converter did not act on the input at all
- Succeeded, meaning the conversion worked and the result is included
- Failed, meaning the conversion was attempted but threw an error
Inside the converter you check whether the incoming binding data actually belongs to the extension you support, since the extension name is passed along as part of the model binding data, and whether the content type is one you know how to handle. You can also narrow the converter’s scope with one or more SupportedTargetType attributes so it only fires for the types you care about. Here is the shape of a typical converter.
[SupportsDeferredBinding]
[SupportedTargetType(typeof(...))]
[SupportedTargetType(typeof(...))]
internal class RethinkDbConverter : IInputConverter
{
private const string RETHINKDB_EXTENSION_NAME = "RethinkDB";
private const string JSON_CONTENT_TYPE = "application/json";
...
public RethinkDbConverter(...)
{
...
}
public async ValueTask<ConversionResult> ConvertAsync(ConverterContext context)
{
ModelBindingData modelBindingData = context?.Source as ModelBindingData;
if (modelBindingData is null)
{
return ConversionResult.Unhandled();
}
try
{
if (modelBindingData.Source is not RETHINKDB_EXTENSION_NAME)
{
throw new InvalidOperationException($"Unexpected binding source.");
}
if (modelBindingData.ContentType is not JSON_CONTENT_TYPE)
{
throw new InvalidOperationException($"Unexpected content-type.");
}
object result = context.TargetType switch
{
// Here you can use modelBindingData.Content,
// any injected services, etc.
// to prepare the value.
...
};
return ConversionResult.Success(result);
}
catch (Exception ex)
{
return ConversionResult.Failed(ex);
}
}
}
Notice that everything happens on the worker side here, the source check, the content type check, and the actual construction of the target object from modelBindingData.Content. Common mistakes with this pattern include forgetting the null check on modelBindingData, which throws an unhelpful exception deep in the pipeline, and not validating the extension name, which lets a converter silently misfire when multiple extensions are registered in the same app. Wrap the conversion logic in a try catch and return ConversionResult.Failed rather than letting exceptions bubble up unhandled, otherwise the failure message a caller sees is far less useful than what the converter itself already knows.
To actually use a converter, you decorate the binding attribute with it and set a fallback policy that decides what happens when the converter declines to handle a particular input.
[InputConverter(typeof(RethinkDbConverter))]
[ConverterFallbackBehavior(ConverterFallbackBehavior.Default)]
public class RethinkDbInputAttribute : InputBindingAttribute
{
...
}
Adding a converter shifts real work from the host to the worker. The worker may now open connections to a downstream service, read configuration, or hold on to other stateful resources, which is a meaningfully different responsibility than what worker extensions did before converters existed. That kind of setup needs to happen once, at application startup, not on every invocation.
Hooking Into Function App Startup
Isolated worker extensions can implement a startup hook by creating a public class with a parameterless constructor that derives from WorkerExtensionStartup, then registering that class through another assembly level attribute. Overriding the Configure method gives you access to the same kind of service and middleware registration that in-process extensions have had for years.
[assembly: WorkerExtensionStartup(typeof(RethinkDbExtensionStartup))]
namespace Microsoft.Azure.Functions.Worker
{
public class RethinkDbExtensionStartup : WorkerExtensionStartup
{
public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder)
{
if (applicationBuilder == null)
{
throw new ArgumentNullException(nameof(applicationBuilder));
}
...
}
}
}
This is where you would register the connection factory, the HTTP client, or whatever else your converter needs at runtime, using the regular ASP.NET Core dependency injection container that backs the isolated worker process. It mirrors what you would do in an in-process extension’s IWebJobsStartup, just wired through a different builder interface.
Trade-offs Worth Knowing Before You Build One of These
None of this is aimed at day to day Function app development. It matters if you are building or maintaining a custom binding extension, or you maintain middleware that a lot of functions depend on. For plain POCO based bindings, the isolated model is close to transparent and you rarely need to think about any of this.
The build time NuGet resolution through ExtensionInformation is convenient until it is not. Pin your in-process package version carefully and test the combination explicitly, because a worker package upgrade can silently pull in a different in-process version than the one you validated. If your extension exposes rich SDK types, converters are unavoidable, but remember they move connection management and configuration reading into the worker process, so plan for that resource lifecycle the same way you would for any other singleton service in an ASP.NET Core app.
The serialization boundary between host and worker is the real limitation here. Anything that cannot be represented as data that survives a gRPC round trip has to go through a converter, and anything that genuinely cannot be represented that way at all simply is not available in the isolated model. For most teams building line of business functions this never comes up, but if you are porting an existing in-process extension that leans heavily on SDK specific types, budget real time for writing and testing converters rather than assuming a drop-in port.
Leave a Reply