Mert Özen Further With Every Line
DevOps Cloud Fundamentals — #06

Background Work with Serverless: Azure Functions Isolated Worker, Flex Consumption and Blob-Triggered Cover Image Processing

Mert Özen Nov 15, 2026 15 min 12 views
Background Work with Serverless: Azure Functions Isolated Worker, Flex Consumption and Blob-Triggered Cover Image Processing

Serverless is the right choice for short, irregular work: Bookstore's cover thumbnails are produced by an Event Grid triggered .NET 10 isolated worker function on Flex Consumption. We cover the end of the in-process model, Azure.Functions.Sdk, the hosting plans, idempotency with ETags, an Event Grid event sent by hand locally with Azurite and Core Tools, the poison queue, an identity-based connection and the Lambda equivalents.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Azure.Functions.Sdk 1.1.0, Microsoft.Azure.Functions.Worker 2.52.0, the Blob Storage extension 6.8.2, SkiaSharp 4.156.0, Azure Functions Core Tools 4.16.1, Azurite 3.37.0 and Azure CLI 2.92.0. The function ran locally against Azurite; the Event Grid event was sent by hand, and nothing was deployed to Azure. Plan and limit details come from Microsoft Learn (November 2026).

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, when a store employee uploads a book's cover image, a 320-pixel-wide thumbnail has to be produced for the catalog page. An Azure Function that runs when a new blob arrives does this. Two questions: if Event Grid delivers the same "blob created" event twice, how many times does the function run and how many thumbnails are written? If someone accidentally uploads a text file to the covers container, what happens; does the function retry forever? We tried both locally.

What Serverless Means and Where It Stops

In post 1 we defined serverless as the model where you don't think about servers, it drops to zero when there is no work, and you pay for usage. Function as a Service is its finest-grained form: what you deploy isn't an application but functions bound to events. Cover image processing fits this model perfectly: a few hundred times a month, at irregular times, a job of a few hundred milliseconds.

The limits are clear too. First, duration: according to Learn, on Flex Consumption the default timeout of an execution is 30 minutes and there is no upper bound, but a running execution gets a 60-minute grace period during scale-in and 10 minutes during platform updates; an HTTP-triggered function has to respond within 230 seconds whatever you configure. On AWS Lambda a single invocation runs at most 15 minutes. Second, cold start: the first request on an instance starting from zero is slow, just like what we measured for the API in post 4. Third, state: you can't carry anything in memory between two executions; everything has to live outside. Fourth, lock-in: triggers, bindings and the hosting model are provider-specific; moving to another cloud means rewriting the code.

The Isolated Worker Model and Azure.Functions.Sdk

For years .NET functions were written in two models. In the in-process model the code ran inside the Functions host's process. In the isolated worker model your code runs in its own .NET process and talks to the host over gRPC; you can choose the .NET version independently of the host and use dependency injection and middleware as in any .NET application. According to Learn, support for the in-process model ended on 10 November 2026; .NET 10 runs only in the isolated worker model, and Flex Consumption never supported the in-process model. The samples you find online with the [FunctionName] attribute and the Microsoft.NET.Sdk.Functions package belong to that old model.

The project file got simpler as well. The new Azure.Functions.Sdk is an MSBuild project SDK that replaces the old Microsoft.Azure.Functions.Worker.Sdk package: it adds the source generators that produce function metadata, sets AzureFunctionsVersion to v4, and starts the host with dotnet run when Core Tools is installed. Bookstore's new project:

<Project Sdk="Azure.Functions.Sdk">

  <PropertyGroup>
    <RootNamespace>Bookstore.Functions</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Worker" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs" />
    <PackageReference Include="SkiaSharp" />
    <!-- Flex Consumption runs on Linux: native Skia library without extra system packages -->
    <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" />
  </ItemGroup>

</Project>

The project file has no SDK version, because it was added to the global.json at the repo root:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  },
  "msbuild-sdks": {
    "Azure.Functions.Sdk": "1.1.0"
  }
}

Package versions are kept centrally in Directory.Packages.props, like the other projects in the repo. Learn recommends at least 2.50.0 of Microsoft.Azure.Functions.Worker for .NET 10; we use 2.52.0. Program.cs is a few lines:

using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;

FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder(args);

builder.Build().Run();

Triggers, Bindings and Hosting Plans

Every function has exactly one trigger: an HTTP request, a timer (a cron expression), a message on a Storage or Service Bus queue, a new blob. Bindings are inputs and outputs that make it easier for the function to talk to other services. In the isolated model bindings can hand you Azure SDK types directly: you can take a BlobClient or a BlobContainerClient and do whatever you need.

The blob trigger has two implementations. The older polling-based one scans the container at intervals and reads the storage logs; Learn says that on the Consumption plan processing a new blob can be delayed by up to 10 minutes when the app is idle, and that logs are written on a "best effort" basis. The Event Grid based one is triggered immediately through an event subscription on the storage account, and Flex Consumption supports only this one. In Bookstore we use the Event Grid based trigger.

There are four hosting options. Flex Consumption is the recommended plan for new serverless apps: Linux only, code deployment, instance sizes of 512 MB, 2,048 MB or 4,096 MB, VNet integration and always ready instances to reduce cold starts. The Premium plan runs on instances that are kept warm and suits long or constant work. The Dedicated plan runs the function on an App Service plan alongside other web apps. The fourth is Container Apps: you put the function as a container image into the environment from post 5. The Linux version of the old Consumption plan retires on 30 September 2028 and can't run .NET 10 apps anyway; the Windows version isn't affected for now.

The Cover Thumbnail: The Function's Code

We separated the resizing from the function so it can be tested. For image processing we use the MIT-licensed SkiaSharp; because Flex Consumption runs on Linux, the project also has the SkiaSharp.NativeAssets.Linux.NoDependencies package, which needs no extra system packages:

using SkiaSharp;

namespace Bookstore.Functions;

public static class CoverThumbnail
{
    public const int Width = 320;

    // Keeps the aspect ratio; never enlarges a small cover
    public static (int Width, int Height) FitToWidth(int width, int height, int maxWidth)
    {
        if (width <= 0 || height <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(width), "Image size must be positive.");
        }
        if (width <= maxWidth)
        {
            return (width, height);
        }
        return (maxWidth, (int)Math.Round(height * (double)maxWidth / width));
    }

    public static byte[] CreateWebp(Stream source, int maxWidth = Width, int quality = 80)
    {
        using SKBitmap original = SKBitmap.Decode(source)
            ?? throw new InvalidDataException("The blob is not an image Skia can decode.");
        (int width, int height) = FitToWidth(original.Width, original.Height, maxWidth);
        using SKBitmap resized = original.Resize(new SKImageInfo(width, height), new SKSamplingOptions(SKCubicResampler.Mitchell));
        using SKImage image = SKImage.FromBitmap(resized);
        using SKData data = image.Encode(SKEncodedImageFormat.Webp, quality);
        return data.ToArray();
    }
}

The function itself:

using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace Bookstore.Functions;

public sealed class CoverThumbnailFunction(ILogger<CoverThumbnailFunction> logger)
{
    // Flex Consumption supports only the Event Grid based blob trigger
    [Function(nameof(CoverThumbnail))]
    public async Task CoverThumbnail(
        [BlobTrigger("covers/{name}", Source = BlobTriggerSource.EventGrid, Connection = "CoversStorage")] BlobClient cover,
        [BlobInput("covers-thumbnails", Connection = "CoversStorage")] BlobContainerClient thumbnails,
        string name,
        CancellationToken cancellationToken)
    {
        BlobProperties source = await cover.GetPropertiesAsync(cancellationToken: cancellationToken);
        string sourceETag = source.ETag.ToString();
        BlobClient thumbnail = thumbnails.GetBlobClient(Path.ChangeExtension(name, ".webp"));

        // Event Grid delivers at least once: the same version must not be processed twice
        try
        {
            BlobProperties existing = await thumbnail.GetPropertiesAsync(cancellationToken: cancellationToken);
            if (existing.Metadata.TryGetValue("sourceetag", out string? processed) && processed == sourceETag)
            {
                logger.LogInformation("Thumbnail for {Name} is up to date (ETag {ETag}), skipping", name, sourceETag);
                return;
            }
        }
        catch (RequestFailedException ex) when (ex.Status == 404)
        {
        }

        await using Stream image = await cover.OpenReadAsync(cancellationToken: cancellationToken);
        byte[] webp = Bookstore.Functions.CoverThumbnail.CreateWebp(image);

        await thumbnails.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
        await thumbnail.UploadAsync(new BinaryData(webp), new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders { ContentType = "image/webp", CacheControl = "public, max-age=31536000" },
            Metadata = new Dictionary<string, string> { ["sourceetag"] = sourceETag },
        }, cancellationToken);

        logger.LogInformation("Created {Thumbnail} ({Bytes} bytes) from {Name} ({SourceBytes} bytes)",
            thumbnail.Name, webp.Length, name, source.ContentLength);
    }
}

Three decisions are worth explaining. Source = BlobTriggerSource.EventGrid says the trigger is Event Grid based. Connection = "CoversStorage" isn't a connection string but a setting prefix: locally CoversStorage=UseDevelopmentStorage=true, on Azure managed identity settings such as CoversStorage__blobServiceUri. Most important is idempotency, doing the same work twice giving the same result as doing it once: Event Grid delivers events at least once, so the same event can arrive twice. We write the source blob's ETag into the thumbnail's metadata; a second execution for the same ETag exits without writing anything. If the cover changes, the ETag changes and the thumbnail is produced again. The post "Background Job'larda Retry ve Idempotency: At-Least-Once Gerçeğiyle Yaşamak" in the Background Job'lar ve Scheduled Job'lar series covers the same topic for jobs in detail.

In host.json we turned the log levels down and limited concurrency; for local settings we put a sample file in the repo, and the real local.settings.json is in .gitignore:

{
  "version": "2.0",
  "logging": {
    "logLevel": {
      "default": "Warning",
      "Function": "Information",
      "Host.Results": "Information"
    }
  },
  "extensions": {
    "blobs": {
      "maxDegreeOfParallelism": 4
    }
  }
}
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "CoversStorage": "UseDevelopmentStorage=true"
  }
}

The tests for the resizing logic are in a separate project, tests/Bookstore.Functions.Tests:

using SkiaSharp;

namespace Bookstore.Functions.Tests;

public class CoverThumbnailTests
{
    [Theory]
    [InlineData(1200, 1800, 320, 480)]   // typical portrait cover
    [InlineData(1600, 900, 320, 180)]    // landscape box set image
    [InlineData(200, 300, 200, 300)]     // smaller than the target: unchanged
    public void FitToWidth_keeps_the_aspect_ratio(int width, int height, int expectedWidth, int expectedHeight)
    {
        Assert.Equal((expectedWidth, expectedHeight), CoverThumbnail.FitToWidth(width, height, 320));
    }

    [Fact]
    public void CreateWebp_resizes_a_jpeg_cover()
    {
        using SKBitmap bitmap = new(1200, 1800);
        using (SKCanvas canvas = new(bitmap))
        {
            canvas.Clear(SKColors.SteelBlue);
        }
        using SKData jpeg = SKImage.FromBitmap(bitmap).Encode(SKEncodedImageFormat.Jpeg, 90);
        using MemoryStream source = new(jpeg.ToArray());

        byte[] webp = CoverThumbnail.CreateWebp(source);

        using SKBitmap thumbnail = SKBitmap.Decode(webp);
        Assert.Equal((320, 480), (thumbnail.Width, thumbnail.Height));
    }

    [Fact]
    public void CreateWebp_rejects_a_file_that_is_not_an_image()
    {
        using MemoryStream source = new("not an image"u8.ToArray());

        Assert.Throws<InvalidDataException>(() => CoverThumbnail.CreateWebp(source));
    }
}

dotnet test on the whole solution: 18 API tests and 5 function tests.

Test run summary: Passed!
  total: 23
  failed: 0
  succeeded: 23
  skipped: 0

Running Locally: Azurite, Core Tools and Imitating Event Grid

Locally you need three pieces: the Azurite emulator for storage, Azure Functions Core Tools for the Functions host, and an event. On Windows you can install Core Tools with the MSI package from Learn or with npm install -g azure-functions-core-tools@4. We started Azurite as a container; --skipApiVersionCheck keeps requests from being rejected when the SDK asks for a newer REST version than Azurite knows:

$ docker run -d --name bookstore-azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 \
    mcr.microsoft.com/azure-storage/azurite \
    azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck

We uploaded the 1200×1800-pixel cover of Dune (ISBN 9780441172719) with the Azure CLI. UseDevelopmentStorage=true is the shortcut for Azurite's well-known local account; you don't have to type the key:

$ az storage container create --name covers --connection-string "UseDevelopmentStorage=true"
{
  "created": true
}
$ az storage blob upload --container-name covers --name 9780441172719.jpg \
    --file 9780441172719.jpg --content-type image/jpeg --connection-string "UseDevelopmentStorage=true"

Then dotnet run in the function project. Azure.Functions.Sdk finds Core Tools and starts the host:

Azure Functions Core Tools
Core Tools Version:       4.16.1+f1ade740bcec588a2f068c53a46fb3e0acf525c5 (64-bit)
Function Runtime Version: 4.1054.250.26428

[2026-11-14T19:59:05.448Z] Worker process started and initialized.

Functions:

	CoverThumbnail: blobTrigger

At startup you'll see a few "404 The specified queue does not exist" lines that the Azure SDK logs for queues that don't exist yet; the extension creates the queues when it first needs them, and these lines aren't errors. Even though the blob was uploaded, the function didn't run, because there is no Event Grid locally. We do what Event Grid would do by hand: we send a BlobCreated event to the blob extension's webhook. The body is a single-element array in the Event Grid schema; data.url is the blob's address, and the aeg-event-type: Notification header says it's a notification:

$ curl -s -i -X POST "http://localhost:7071/runtime/webhooks/blobs?functionName=Host.Functions.CoverThumbnail" \
    -H "Content-Type: application/json" -H "aeg-event-type: Notification" --data @blobcreated.json
HTTP/1.1 202 Accepted
[2026-11-14T19:59:19.223Z] Executing 'Functions.CoverThumbnail' (Reason='New blob detected(EventGrid): covers/9780441172719.jpg', Id=a9d640f0-f8ff-41b2-90f5-28433f10a9b0)
[2026-11-14T19:59:19.224Z] Trigger Details: MessageId: f2395ee5-c4b8-4f08-9ef6-a4c2089ae7a9, DequeueCount: 1, InsertedOn: 2026-11-14T19:59:19.000+00:00, BlobCreated: 2026-11-14T19:58:38.000+00:00, BlobLastModified: 2026-11-14T19:58:38.000+00:00
[2026-11-14T19:59:19.606Z] Created 9780441172719.webp (754 bytes) from 9780441172719.jpg (57117 bytes)
[2026-11-14T19:59:19.611Z] Executed 'Functions.CoverThumbnail' (Succeeded, Id=a9d640f0-f8ff-41b2-90f5-28433f10a9b0, Duration=399ms)

The MessageId and DequeueCount in the "Trigger Details" line are interesting: the extension first writes the event that reached the webhook to an internal queue in storage and runs the function from there. That's why on Azure the function's identity needs a queue role besides the blob role. Sending the same event a second time:

[2026-11-14T19:59:29.523Z] Executing 'Functions.CoverThumbnail' (Reason='New blob detected(EventGrid): covers/9780441172719.jpg', Id=deecfbed-381e-4457-b045-2544b6ca8eb2)
[2026-11-14T19:59:29.532Z] Thumbnail for 9780441172719.jpg is up to date (ETag "0x27523C388165980"), skipping
[2026-11-14T19:59:29.532Z] Executed 'Functions.CoverThumbnail' (Succeeded, Id=deecfbed-381e-4457-b045-2544b6ca8eb2, Duration=11ms)

The first answer to the guess: the function runs twice, the thumbnail is written once. With the polling-based trigger, the blob receipt mechanism prevented the same blob version from being processed twice; with the Event Grid based trigger, the code has to handle duplicate deliveries. The result in Azurite:

$ az storage blob list --container-name covers-thumbnails --include m --connection-string "UseDevelopmentStorage=true" \
    --query "[].{name:name, size:properties.contentLength, sourceetag:metadata.sourceetag}" -o table
Name                Size    Sourceetag
------------------  ------  -------------------
9780441172719.webp  754     "0x27523C388165980"

Now the second question. We uploaded a 12-byte readme.txt to covers and sent its event:

[2026-11-14T19:59:43.650Z] Exception: System.IO.InvalidDataException: The blob is not an image Skia can decode.
[2026-11-14T19:59:43.853Z] Executed 'Functions.CoverThumbnail' (Failed, Id=6f0eea91-afc8-4ef7-85b6-3d243f1bde23, Duration=226ms)
...
[2026-11-14T19:59:44.260Z] Trigger Details: MessageId: e6cc6eb8-47ed-4b02-89b1-719b91eccbe5, DequeueCount: 5, ...
[2026-11-14T19:59:44.270Z] Message has reached MaxDequeueCount of 5. Moving message to queue 'webjobs-blobtrigger-poison'.

The function tries five times, then moves the message to the webjobs-blobtrigger-poison queue and gives up. The message in the queue carries enough information to investigate which blob couldn't be processed:

{
  "Type": "BlobTrigger",
  "FunctionId": "Host.Functions.CoverThumbnail",
  "BlobType": "BlockBlob",
  "ContainerName": "covers",
  "BlobName": "readme.txt",
  "ETag": "\"\""
}

Five attempts is the default on Learn; it can be changed with poisonBlobThreshold in host.json. An image that can't be decoded won't be fixed by retrying, so five attempts are wasted here; it's better to put a subject filter for .jpg and .png on the event subscription and validate the file type in the upload endpoint (post 7). Connect the poison queue to an alert too; if nobody notices it filling up, covers silently stay without thumbnails.

Moving to Azure: An Identity-Based Connection and an Event Subscription

The Azure side is a script. It creates the function app on Flex Consumption, attaches the identity from post 3, grants the necessary roles and creates an event subscription on the storage account:

#!/usr/bin/env bash
# Bookstore.Functions on Flex Consumption: cover thumbnails, identity-based storage access, Event Grid subscription.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 06-functions.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
FUNC="func-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
HOST_STORAGE="stbookstorefn${ENVIRONMENT}weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
IDENTITY_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query id --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)
PRINCIPAL_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query principalId --output tsv)
STORAGE_ID=$(az storage account show --name "$STORAGE" --resource-group "$RG" --query id --output tsv)

# The Functions host keeps its own state (deployment package, triggers' queues) in a separate account
az storage account create --name "$HOST_STORAGE" --resource-group "$RG" --location westeurope \
  --sku Standard_LRS --allow-blob-public-access false

az functionapp create --name "$FUNC" --resource-group "$RG" \
  --storage-account "$HOST_STORAGE" --flexconsumption-location westeurope \
  --runtime dotnet-isolated --runtime-version 10.0 \
  --instance-memory 2048 --maximum-instance-count 20 \
  --assign-identity "$IDENTITY_ID"

# The trigger reads covers and writes covers-thumbnails; the blob trigger also needs queues on that account
az storage container-rm create --storage-account "$STORAGE" --name covers-thumbnails
for ROLE in "Storage Blob Data Owner" "Storage Queue Data Contributor"; do
  az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
    --role "$ROLE" --scope "$STORAGE_ID"
done

# Identity-based connection: no connection string, only the account's service URIs and the identity
az functionapp config appsettings set --name "$FUNC" --resource-group "$RG" --settings \
  "CoversStorage__blobServiceUri=https://${STORAGE}.blob.core.windows.net" \
  "CoversStorage__queueServiceUri=https://${STORAGE}.queue.core.windows.net" \
  "CoversStorage__credential=managedidentity" \
  "CoversStorage__clientId=${CLIENT_ID}"

(cd src/Bookstore.Functions && func azure functionapp publish "$FUNC")

# Event Grid calls the blob extension's webhook; the key protects it
KEY=$(az functionapp keys list --name "$FUNC" --resource-group "$RG" --query "systemKeys.blobs_extension" --output tsv)
ENDPOINT="https://${FUNC}.azurewebsites.net/runtime/webhooks/blobs?functionName=Host.Functions.CoverThumbnail&code=${KEY}"
az eventgrid event-subscription create --name covers-created \
  --source-resource-id "$STORAGE_ID" --endpoint "$ENDPOINT" \
  --included-event-types Microsoft.Storage.BlobCreated \
  --subject-begins-with /blobServices/default/containers/covers/blobs/

The roles come from the table on Learn: the blob trigger needs both "Storage Blob Data Owner" and "Storage Queue Data Contributor", because the extension keeps the internal queue and the poison queue in the same account; that's also why the CoversStorage__queueServiceUri setting is mandatory. In post 3 we gave Bookstore.Api write access only on the covers container; the function needs a broader role at account scope, so giving the function its own identity (such as id-bookstore-fn-dev-weu) is the cleaner option. We used the same identity to keep the script short; it's a trade-off, and we fix it in A Small Experiment. The code parameter in the webhook address is the function app's blobs_extension system key; it keeps anyone other than Event Grid from posting events to that address. We checked the script with ShellCheck but did not run it against Azure.

When Not to Use Functions

The economics of Functions work for irregular, short work. For a report that runs for hours over thousands of orders on a campaign night, a worker draining a constantly full queue, or a service holding a large model in memory, there are better options: Container Apps jobs (scheduled or event-triggered containers that stop when done), a BackgroundService running inside the API, or a persistent job system such as Hangfire. The Background Job'lar ve Scheduled Job'lar series compares these options. Under constant load the per-second price of Flex Consumption can end up more expensive than a fixed plan.

TopicAzure Functions (Flex Consumption)AWS Lambda
New blob triggerBlob trigger with Event Grid sourceS3 event notification or EventBridge
Execution time limit30 minutes by default, no upper bound; HTTP response within 230 secondsAt most 15 minutes
Memory512, 2,048 or 4,096 MB instances128 MB to 10,240 MB
Reducing cold startsAlways ready instancesProvisioned concurrency
Access to storageManaged identity and identity-based connectionsExecution role
Failed eventsPoison queueDead-letter queue or on-failure destination

In Production

Separate host storage from business storage. The Functions host uses its own storage account for the deployment package and internal queues; that's why stbookstorefn... in the script is separate. Putting the host's load on the account that holds the covers makes the two workloads affect each other.

Don't recreate HttpClient and SDK clients on every execution. The function runs on every event; opening a new HttpClient each time leads to socket exhaustion. Register clients in DI once or use the client the binding gives you; Bookstore's function does the latter.

Watch the executions. Failed execution counts, the poison queue length and the duration distribution should be visible through the Application Insights and OpenTelemetry setup covered in the Observability series.

Common Pitfalls

Copying old in-process samples. A sample with [FunctionName], Microsoft.NET.Sdk.Functions and an ILogger log parameter belongs to the model whose support ended on 10 November 2026; it doesn't run on .NET 10 or on Flex Consumption.

Using the polling-based blob trigger on Flex Consumption. Flex supports only the Event Grid sourced trigger; without an event subscription the function is never triggered, and it doesn't report an error either.

Skipping idempotency because "it runs once anyway". Event Grid and queues deliver at least once; double execution isn't rare, it's normal.

Binding a large blob as string or byte[]. Learn says that with these types the whole blob is loaded into memory more than once; for large files use Stream or BlobClient.

A Small Experiment

★☆☆ Add "poisonBlobThreshold": 1 to host.json and repeat the text file test; after which attempt does the message land in the poison queue?

★★☆ Add a second size to the function: also produce a 640-pixel thumbnail. Adjust the idempotency check so it works correctly for both outputs, and write its tests.

★★★ Change the script so the function uses a separate user-assigned identity (id-bookstore-fn-dev-weu). Compare the permissions of Bookstore.Api and the function in a table, and write down which role sits at which scope and why.

In the next post we go down to where the covers live: containers, redundancy and access tier options in Blob Storage, temporary access with a user delegation SAS, and a cover upload endpoint for Bookstore.Api.