← Back to DevBytes

Scaling Event Grid: From Prototype to Production

Scaling Event Grid: From Prototype to Production

Azure Event Grid is a fully managed event routing service that enables you to build reactive, event-driven applications with minimal infrastructure overhead. While building a prototype with Event Grid is deceptively simple—create a topic, subscribe, and publish—moving that same architecture to production introduces a host of challenges around throughput, reliability, ordering, cost, and observability. This tutorial walks you through the journey from a working prototype to a hardened, production-grade event-driven system.

What Is Event Grid?

Event Grid is a publish-subscribe service that routes events from sources (such as Azure services, custom applications, or third-party systems) to handlers (such as Azure Functions, Logic Apps, webhooks, or Service Bus queues). Unlike message brokers such as Service Bus or Kafka, Event Grid is optimized for lightweight, discrete event notifications rather than long-lived message streams or heavy payloads.

Event Grid supports two primary resource models:

Understanding which model fits your workload is the first step in scaling successfully.

Why Scaling Matters

In a prototype, you might publish a few hundred events per minute to a single topic with one subscriber. In production, you may need to handle millions of events per hour, fan out to dozens of subscribers, guarantee delivery under partial failures, and keep costs predictable. Without deliberate design, you will hit bottlenecks such as:

The good news is that Event Grid is designed to scale horizontally. The challenge is configuring and orchestrating it correctly.

From Prototype to Production: A Practical Walkthrough

Let us start with a typical prototype. You have an Azure Function that publishes order events to a custom topic, and another Function subscribed to that topic that updates inventory. Here is the prototype publisher:

using Azure.Messaging.EventGrid;
using System.Threading.Tasks;

public class OrderPublisher
{
    private readonly EventGridPublisherClient _client;

    public OrderPublisher(string topicEndpoint, string topicKey)
    {
        _client = new EventGridPublisherClient(
            new Uri(topicEndpoint),
            new AzureKeyCredential(topicKey));
    }

    public async Task PublishOrderAsync(Order order)
    {
        var eventGridEvent = new EventGridEvent(
            subject: $"orders/{order.Id}",
            eventType: "order.created",
            dataVersion: "1.0",
            data: new BinaryData(order));

        await _client.SendEventAsync(eventGridEvent);
    }
}

This works fine for a prototype. But in production, several improvements are necessary.

1. Batch Your Publishes

Each call to SendEventAsync incurs network latency and is billed as a single operation. At scale, you should batch events using SendEventsAsync:

public async Task PublishOrdersBatchAsync(IEnumerable<Order> orders)
{
    var events = orders.Select(order => new EventGridEvent(
        subject: $"orders/{order.Id}",
        eventType: "order.created",
        dataVersion: "1.0",
        data: new BinaryData(order))).ToList();

    // Event Grid accepts up to 1 MB per batch and 100,000 events per second per topic
    await _client.SendEventsAsync(events);
}

Batching reduces per-event overhead and improves throughput dramatically. Keep each batch under the 1 MB size limit, and consider chunking large batches accordingly.

2. Use Event Grid Domains for Multi-Tenancy

If you are serving multiple tenants or business units, creating a separate topic for each one becomes operationally expensive. Event Grid Domains let you publish to many topics through a single endpoint using a topic name in the event's channel header:

var eventGridEvent = new EventGridEvent(
    subject: $"tenantA/orders/{order.Id}",
    eventType: "order.created",
    dataVersion: "1.0",
    data: new BinaryData(order));

// The topic name is specified when sending to a domain
await _domainClient.SendEventAsync(eventGridEvent, "tenantA-orders");

Subscribers can then attach to individual topics within the domain, with independent filtering and dead-letter configuration per tenant.

3. Configure Dead Letter and Retry Policies

In production, subscribers will fail. Without a dead-letter destination, failed events are silently dropped after the retry policy is exhausted. Always configure a dead-letter endpoint, typically a Storage Account container:

az eventgrid event-subscription create \
  --name order-processor-sub \
  --source-resource-id /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/topics/{topic} \
  --endpoint-type azurefunction \
  --endpoint /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web/sites/{functionApp}/functions/ProcessOrder \
  --deadletter-endpoint /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{storage}/blobServices/default/containers/eventgrid-deadletter \
  --max-delivery-attempts 10 \
  --event-ttl 1440

This configuration retries delivery up to 10 times with exponential backoff and stores undeliverable events in a blob container for later inspection and replay.

4. Implement Idempotent Subscribers

Event Grid provides at-least-once delivery. This means your subscriber may receive the same event more than once, especially during retries or failover. Your handlers must be idempotent. A common pattern is to track processed event IDs:

public static class ProcessOrder
{
    [FunctionName("ProcessOrder")]
    public static async Task Run(
        [EventGridTrigger] EventGridEvent eventGridEvent,
        [CosmosDB("orders", "processed-events", ConnectionStringSetting = "CosmosDb")] 
            IAsyncCollector<ProcessedEvent> processedStore,
        ILogger log)
    {
        var eventId = eventGridEvent.Id;

        // Check if already processed (idempotency guard)
        if (await AlreadyProcessedAsync(eventId))
        {
            log.LogInformation($"Event {eventId} already processed. Skipping.");
            return;
        }

        var order = eventGridEvent.Data.ToObjectFromJson<Order>();
        await UpdateInventoryAsync(order);

        await processedStore.AddAsync(new ProcessedEvent
        {
            Id = eventId,
            ProcessedAt = DateTime.UtcNow
        });
    }
}

Using Cosmos DB with a unique constraint on the event ID provides a strong idempotency guarantee even under concurrent deliveries.

5. Filter Aggressively at the Subscription Level

One of the most overlooked scaling techniques is filtering. Event Grid supports subject, event type, and advanced attribute filtering. Pushing filtering down to Event Grid means subscribers only receive events they actually care about, reducing load and cost:

az eventgrid event-subscription create \
  --name high-value-orders-sub \
  --source-resource-id /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/topics/{topic} \
  --endpoint-type azurefunction \
  --endpoint /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web/sites/{functionApp}/functions/ProcessHighValueOrder \
  --included-event-types order.created \
  --subject-begins-with "orders/premium/" \
  --advanced-filter data.amount GreaterThan 1000

This subscription only receives premium orders over $1,000. Other events never reach the subscriber, saving compute and delivery costs.

6. Handle Backpressure with Service Bus Integration

Webhook and Azure Function subscribers have concurrency limits. If your event volume spikes, the subscriber may become overwhelmed and start returning 429 or 5xx responses, triggering retries and amplifying load. A robust pattern is to use Event Grid to push events into a Service Bus queue, then have your worker consume from the queue at its own pace:

az eventgrid event-subscription create \
  --name orders-to-servicebus \
  --source-resource-id /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/topics/{topic} \
  --endpoint-type servicebusqueue \
  --endpoint /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ServiceBus/namespaces/{namespace}/queues/orders

Service Bus provides buffering, dead-letter queues, sessions for ordering, and competing consumers for horizontal scaling. This decouples Event Grid's delivery speed from your processing speed.

7. Enable Diagnostic Logging and Tracing

In production, you need visibility into delivery failures, latency, and dropped events. Enable diagnostic settings on your Event Grid topic to send logs to Log Analytics:

az monitor diagnostic-settings create \
  --name eventgrid-diagnostics \
  --resource /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/topics/{topic} \
  --workspace /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.OperationalInsights/workspaces/{workspace} \
  --logs '[{"category":"DeliveryFailures","enabled":true},{"category":"PublishFailures","enabled":true}]' \
  --metrics '[{"category":"AllMetrics","enabled":true}]'

You can then query delivery failures in Kusto:

EventGridLogs
| where CategoryName == "DeliveryFailures"
| project TimeGenerated, EventId, Subject, EventType, Error
| order by TimeGenerated desc

For end-to-end tracing, propagate a correlation ID in the event data and log it at every hop. This lets you trace a single event from publisher through Event Grid to the final subscriber.

Best Practices Summary

Conclusion

Scaling Azure Event Grid from prototype to production is less about raw throughput—which the service handles natively—and more about thoughtful architecture around it. By batching publishes, embracing domains for multi-tenancy, configuring dead-lettering and retries, building idempotent subscribers, filtering aggressively, buffering with Service Bus when needed, and investing in observability, you can build an event-driven system that remains reliable and cost-effective under real-world load. The prototype gets you to a working demo; these production patterns keep you running when traffic, tenants, and complexity grow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles