Free Ebook cover Serverless on Azure: Building with Azure Functions and Event Triggers

Serverless on Azure: Building with Azure Functions and Event Triggers

New course

10 pages

Cost Awareness and Practical Use Cases: Webhooks, Schedulers, and Queue Processing

Capítulo 10

Estimated reading time: 12 minutes

+ Exercise

Cost awareness: what actually drives your Azure Functions bill

Cost predictability starts with knowing which meters move when your functions run. Your goal is to align the hosting plan and runtime behavior with the workload shape (spiky vs steady, latency-sensitive vs batch), then remove waste caused by unnecessary executions, long durations, and retry amplification.

Key pricing drivers to watch

  • Executions (invocations): Every trigger event can create an execution. High-frequency webhooks, chatty schedulers, and queue retry storms can multiply this quickly.
  • Duration: Billed compute time grows with slow I/O, heavy CPU work, and waiting on external services. “Waiting” is still time.
  • Memory allocation: Higher memory tiers cost more per time unit. Over-allocating memory for lightweight work wastes money; under-allocating can slow execution and increase duration.
  • Premium baseline: Premium plans have a baseline cost for pre-warmed instances and reserved capacity. This can be cost-effective for steady traffic or strict latency requirements, but it is not “pay only when used.”
  • Downstream services: Storage transactions, queue operations, database RU/s or DTUs, and outbound network calls can dominate total cost even if function compute is cheap.

Practical techniques to reduce waste (without sacrificing reliability)

  • Respond fast, process async: For HTTP entry points, validate and enqueue work, then return quickly. This reduces duration and avoids holding connections.
  • Efficient dependencies: Prefer lightweight libraries, avoid loading large SDKs when a small REST call suffices, and reuse clients (for example, HttpClient) to reduce overhead and latency.
  • Right-size the plan: Use Consumption for bursty, non-latency-critical workloads; consider Premium when you need predictable low latency, VNet integration, or sustained throughput. Validate with load tests and cost estimates rather than assumptions.
  • Control retry storms: Retries can multiply executions and downstream calls. Add backoff, cap retries where appropriate, and separate transient failures from permanent ones (for example, invalid payloads should not retry).
  • Batch where possible: Pull multiple items per call (queue batch size) or process in chunks to reduce per-message overhead, while keeping each execution within safe time limits.
  • Fail fast on bad inputs: Reject invalid requests early (signature mismatch, schema invalid) to avoid wasted compute and downstream cost.
  • Cache configuration and metadata: Avoid repeated Key Vault calls or repeated discovery calls within a hot path; load once per instance where safe.

Mini-solution 1: HTTP webhook receiver that validates signatures and offloads work to a queue

Scenario

You receive webhooks from a third-party system (payments, CRM, shipping). You must verify authenticity, acknowledge quickly (to prevent retries), and process the event reliably even if downstream systems are slow.

Architecture notes

  • Trigger: HTTP-triggered function receives webhook.
  • Validation: Verify signature (HMAC or asymmetric) using a shared secret or public key.
  • Durable handoff: Enqueue a compact message to a queue (Azure Storage Queue or Service Bus) for background processing.
  • Worker: Separate queue-triggered function processes the event and writes results to storage/database.
  • Data: Store raw payload (optional) in Blob Storage for replay/audit; store normalized data in a database.

Reliability considerations

  • Ack quickly: Many webhook providers retry if they don’t receive a 2xx within a short window. Keep the HTTP function minimal.
  • Idempotency key: Use provider event ID (or a hash of payload + timestamp) to deduplicate in the worker.
  • Poison handling: Invalid payloads should be rejected at the edge (400/401) rather than retried via queue.
  • Backpressure: Queue depth acts as a buffer when downstream systems slow down.

Step-by-step: implement the receiver

  • Step 1: Define the contract: Identify required headers (timestamp, signature), event ID field, and payload schema.
  • Step 2: Validate signature: Compute expected signature from the raw request body and compare using constant-time comparison.
  • Step 3: Enqueue minimal work item: Put event ID, type, and a pointer to stored payload (or include payload if small) onto the queue.
  • Step 4: Return 202 Accepted: Indicate the event is accepted for processing.
// C# example (conceptual): HTTP webhook receiver that validates HMAC and enqueues work item public static class WebhookReceiver {     [FunctionName("WebhookReceiver")]     public static async Task<IActionResult> Run(         [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "webhooks/provider")] HttpRequest req,         [Queue("webhook-events", Connection = "StorageConnection")] IAsyncCollector<string> queue,         ILogger log)     {         // 1) Read raw body exactly as sent (signature depends on raw bytes)         string body;         using (var reader = new StreamReader(req.Body, Encoding.UTF8))             body = await reader.ReadToEndAsync();          // 2) Extract required headers         var signatureHeader = req.Headers["X-Signature"].ToString();         var timestampHeader = req.Headers["X-Timestamp"].ToString();          if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(timestampHeader))             return new BadRequestObjectResult("Missing signature headers");          // 3) Reject stale timestamps to reduce replay risk (example window: 5 minutes)         if (!long.TryParse(timestampHeader, out var ts))             return new BadRequestObjectResult("Invalid timestamp");         var eventTime = DateTimeOffset.FromUnixTimeSeconds(ts);         if (DateTimeOffset.UtcNow - eventTime > TimeSpan.FromMinutes(5))             return new UnauthorizedResult();          // 4) Compute expected signature (HMACSHA256)         var secret = Environment.GetEnvironmentVariable("WebhookSigningSecret");         var expected = ComputeHmacSha256Hex(secret, timestampHeader + "." + body);          if (!ConstantTimeEquals(expected, signatureHeader))             return new UnauthorizedResult();          // 5) Parse minimal fields (avoid heavy work here)         var json = JsonDocument.Parse(body);         var eventId = json.RootElement.GetProperty("id").GetString();         var eventType = json.RootElement.GetProperty("type").GetString();          // 6) Enqueue work item (keep it small)         var workItem = JsonSerializer.Serialize(new { id = eventId, type = eventType, receivedAt = DateTimeOffset.UtcNow });         await queue.AddAsync(workItem);          // 7) Respond quickly         return new AcceptedResult();     }      private static string ComputeHmacSha256Hex(string secret, string message)     {         using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));         var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));         return Convert.ToHexString(hash).ToLowerInvariant();     }      private static bool ConstantTimeEquals(string a, string b)     {         if (a == null || b == null || a.Length != b.Length) return false;         var diff = 0;         for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i];         return diff == 0;     } }

Cost control tips specific to webhook receivers

  • Minimize dependencies in the HTTP function: Keep cold start and per-request overhead low.
  • Do not call databases in the request path: Move it to the queue worker to reduce duration and avoid timeouts.
  • Return 202 for async processing: Prevent provider retries that create duplicate executions.
  • Store raw payload only when needed: Blob writes and storage transactions add cost; consider sampling or storing only for certain event types.

Production readiness checklist (webhook receiver)

  • Signature validation implemented with constant-time compare
  • Replay protection (timestamp window and/or nonce) in place
  • Fast response path (no downstream calls before responding)
  • Queue message includes idempotency key (event ID)
  • Clear handling for invalid requests (400/401, no retries)
  • Rate limiting or WAF rules considered for abuse scenarios

Mini-solution 2: Scheduled job with Timer trigger, safe re-entrancy, and state handling

Scenario

You run a scheduled task: reconcile data, poll an external API, generate a report, or expire old records. The job must not overlap with itself, must resume safely after failures, and should not reprocess the same time window repeatedly.

Architecture notes

  • Trigger: Timer-triggered function runs on a cron schedule.
  • State store: Persist a “checkpoint” (last successful run time, cursor, or page token) in storage or database.
  • Lock: Use a distributed lock (blob lease or database lock row) to prevent overlapping runs across scaled-out instances.
  • Work partitioning: For large workloads, the timer function enqueues work items to a queue; workers process in parallel.

Reliability considerations

  • Re-entrancy: Timer triggers can fire again while a previous run is still executing (especially if the job runs long). Ensure only one active run per schedule (or per partition).
  • Catch-up behavior: If the app is down, the next run may need to process missed intervals. Use checkpoints to process deterministically.
  • External API limits: Respect rate limits; use paging and backoff to avoid throttling that increases duration and retries.

Step-by-step: implement a safe timer job

  • Step 1: Choose a checkpoint model: For example, store lastProcessedUtc and process events newer than that.
  • Step 2: Acquire a distributed lock: Use a blob lease (simple and cost-effective) before doing work.
  • Step 3: Load checkpoint: Read the last successful cursor/time.
  • Step 4: Process in bounded batches: Limit per-run work to control duration and cost.
  • Step 5: Update checkpoint only after success: Write the new cursor/time at the end of successful processing.
  • Step 6: Release lock: Always release in a finally block.
// C# example (conceptual): Timer job with blob lease lock + checkpoint public static class ReconcileJob {     [FunctionName("ReconcileJob")]     public static async Task Run(         [TimerTrigger("0 */5 * * * *")] TimerInfo timer,         ILogger log)     {         // Lock container/blob names are illustrative         var storageConn = Environment.GetEnvironmentVariable("StorageConnection");         var blobClient = new BlobContainerClient(storageConn, "locks");         await blobClient.CreateIfNotExistsAsync();          var lockBlob = blobClient.GetBlobClient("reconcile.lock");         await lockBlob.UploadAsync(BinaryData.FromString("lock"), overwrite: true);          var leaseClient = lockBlob.GetBlobLeaseClient();         string leaseId = null;          try         {             // Acquire lease to prevent overlap (short lease, renew if needed)             var lease = await leaseClient.AcquireAsync(TimeSpan.FromSeconds(60));             leaseId = lease.Value.LeaseId;              // Load checkpoint (from Table/Blob/DB; simplified here)             var checkpoint = await LoadCheckpointAsync(storageConn) ?? DateTimeOffset.UtcNow.AddMinutes(-5);              // Do bounded work (example: process last 5 minutes)             var from = checkpoint;             var to = DateTimeOffset.UtcNow;             await ProcessWindowAsync(from, to, log);              // Update checkpoint only after successful processing             await SaveCheckpointAsync(storageConn, to);         }         catch (RequestFailedException ex) when (ex.ErrorCode == "LeaseAlreadyPresent")         {             // Another instance is running; exit without doing work             log.LogInformation("Job already running; skipping this schedule.");         }         finally         {             if (leaseId != null)             {                 try { await leaseClient.ReleaseAsync(); } catch { /* best effort */ }             }         }     }      private static Task<DateTimeOffset?> LoadCheckpointAsync(string conn) => Task.FromResult<DateTimeOffset?>(null);     private static Task SaveCheckpointAsync(string conn, DateTimeOffset value) => Task.CompletedTask;     private static Task ProcessWindowAsync(DateTimeOffset from, DateTimeOffset to, ILogger log) => Task.CompletedTask; }

Cost control tips specific to scheduled jobs

  • Avoid “polling too often”: A one-minute schedule for a task that changes hourly creates unnecessary executions.
  • Bound the work per run: Prevent long durations that increase compute cost and risk timeouts.
  • Use incremental checkpoints: Processing only new data reduces repeated downstream calls and database load.
  • Offload heavy work to a queue: The timer function becomes an orchestrator; workers scale based on backlog.

Production readiness checklist (timer job)

  • Distributed lock prevents overlapping runs
  • Checkpoint stored durably and updated only on success
  • Work is bounded per run (batch size/time window)
  • External API calls respect rate limits and paging
  • Catch-up logic defined for downtime periods
  • Failure mode does not corrupt checkpoint (no skipping data)

Mini-solution 3: Queue-based processor that scales and writes results to storage/database

Scenario

You need to process background tasks: image resizing, document parsing, data enrichment, or sending notifications. Work arrives as messages; processing can be parallelized; results must be persisted.

Architecture notes

  • Ingress: Messages arrive from the webhook receiver, timer job, or other systems.
  • Queue trigger: A function processes messages; scale is driven by queue depth and concurrency settings.
  • Idempotent writes: Use upserts keyed by message ID to avoid duplicates when retries happen.
  • Output: Write results to Blob Storage (files) and/or database (records). Optionally emit an event for downstream consumers.

Reliability considerations

  • Poison messages: Some failures are permanent (bad schema, missing required data). Route these to a dead-letter/poison queue with diagnostics.
  • Retry amplification: If a downstream database is throttling, many messages can fail and retry, multiplying load. Add concurrency limits and backoff.
  • Partial failures: If you write to multiple systems, ensure you can safely retry without duplicating side effects (use idempotency keys and transactional patterns where available).

Step-by-step: implement a scalable queue worker

  • Step 1: Define message schema: Include messageId, type, and a pointer to payload (blob URL/key) if large.
  • Step 2: Validate and deserialize: Fail fast for malformed messages and send to poison handling.
  • Step 3: Process with bounded resources: Limit per-message CPU/memory; stream large blobs rather than loading into memory.
  • Step 4: Persist results idempotently: Use an upsert keyed by messageId or a natural key.
  • Step 5: Emit follow-up events (optional): Enqueue a “completed” message or publish an event for downstream steps.
// C# example (conceptual): Queue-triggered processor writing to Blob + DB public static class QueueProcessor {     [FunctionName("QueueProcessor")]     public static async Task Run(         [QueueTrigger("webhook-events", Connection = "StorageConnection")] string msg,         ILogger log)     {         WorkItem item;         try         {             item = JsonSerializer.Deserialize<WorkItem>(msg);             if (string.IsNullOrWhiteSpace(item.Id))                 throw new InvalidOperationException("Missing id");         }         catch (Exception ex)         {             // In practice: move to poison queue with context             log.LogError(ex, "Invalid message format");             throw; // Let runtime handle retry/poison based on configuration         }          // Example: idempotent write pattern (pseudo)         // 1) Check if already processed (cheap lookup by id)         if (await AlreadyProcessedAsync(item.Id))         {             log.LogInformation("Skipping duplicate {Id}", item.Id);             return;         }          // 2) Do work (call external service, transform data, etc.)         var result = await ProcessAsync(item);          // 3) Persist result (upsert)         await UpsertResultAsync(item.Id, result);          // 4) Mark processed (or rely on upsert uniqueness)         await MarkProcessedAsync(item.Id);     }      private record WorkItem(string Id, string Type, DateTimeOffset ReceivedAt);     private static Task<bool> AlreadyProcessedAsync(string id) => Task.FromResult(false);     private static Task<object> ProcessAsync(WorkItem item) => Task.FromResult<object>(new { ok = true });     private static Task UpsertResultAsync(string id, object result) => Task.CompletedTask;     private static Task MarkProcessedAsync(string id) => Task.CompletedTask; }

Cost control tips specific to queue processing

  • Prevent retry storms with concurrency limits: If a dependency is failing, reduce parallelism to avoid multiplying failures and cost.
  • Optimize message size: Store large payloads in Blob Storage and pass references in the queue message to reduce storage transactions and serialization overhead.
  • Batch database operations: Where possible, group writes (or use bulk APIs) to reduce per-item transaction cost.
  • Choose the right queue technology: Storage Queues are cost-effective for simple workloads; Service Bus can be better for sessions, ordering, and dead-lettering features that reduce custom code and operational cost.

Production readiness checklist (queue processor)

  • Message schema versioned and validated
  • Idempotent processing implemented (dedupe by message/event ID)
  • Poison/dead-letter path defined with diagnostics
  • Concurrency tuned to protect downstream dependencies
  • Database writes are upserts or otherwise safe on retry
  • Large payloads handled via blob references (streaming, not loading entire files)

Putting it together: keeping costs predictable across the three patterns

How costs can spiral in real systems

  • Webhook provider retries cause duplicate HTTP executions and duplicate queue messages if you don’t acknowledge quickly or deduplicate.
  • Timer overlap causes the same window to be processed multiple times, multiplying downstream calls and writes.
  • Queue retry storms happen when a dependency is down: each message retries, increasing executions and duration while also hammering the dependency.

Practical guardrails

  • Edge validation + async handoff: Validate signatures and enqueue; do not do heavy work in HTTP.
  • Lock + checkpoint: Ensure one timer run at a time and process incrementally.
  • Backoff + throttling: When dependencies fail or throttle, reduce concurrency and add delays to avoid amplifying cost.
  • Right-size memory and plan: Measure typical duration and memory; adjust to minimize cost per successful unit of work.

Now answer the exercise about the content:

In an HTTP webhook receiver built with Azure Functions, which approach best improves cost predictability while maintaining reliability when downstream systems are slow?

You are right! Congratulations, now go to the next page

You missed! Try again.

Validating at the edge and offloading work to a queue keeps the HTTP path short, reducing billed duration and preventing webhook retries that multiply executions. Background workers then process reliably even when downstream systems are slow.

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.