Closures as callbacks: improving Blazor performance (part 2).

This post is about Blazor, the Microsoft web framework that allows you to write single-page apps in C# instead of JavaScript. They run either in the browser or on the server. Rendering is done to a virtual DOM. The changes to the virtual DOM are sent as a diff to the browser to make the changes to the real DOM.

In part 1 we saw that using a closure as an event callback could have the unintended side effect of updating the event id inside the diff for every render. This is bad enough, as the Blazor documentation actively encourages you to create them, but it gets worse: It seems two-way-binding is affected as well. A simple test using the component below confirms our suspicion:

Looking at our web socket messages we confirm that with a @bind-value construct our diff for adding a single row to the table increases in size as well. How is this possible? To find out, we turn to the decompilation of the component again:

Lines 40-43 Contain another closure, but this time created by the compiler. The event callback is a setter delegate that captures a reference to item to set its Name. In fact every bind-* construct that binds an attribute value is generated the same way by the razor compiler.

Now we know what causes the ever-increasing diff sizes and when it occurs, we might be able to come up with a solution.

Comparing closures

In part 1 we established that attribute delegates pointing to a closure have the nasty side effect of increasing the size of the diffs sent to the browser, where the new ids these diffs carry for these delegates must be replaced in the list that Blazor maintains for it in the browser, causing a bit of performance loss there. It would be way more efficient if we recognize during diff building that the delegates point to a closure that might be a different instance, but are semantically the same: i.e. point to the same method, and capture the same values/references. So that is what we will set out to do:

The listing above contains the relevant method inside the RenderTreeDiffBuilder called AppendDiffEntriesForAttributeFrame. It compares two render frames for an attribute and determines whether their values are different. It does so by a simple Equals comparison on line 13.

In the old situation, as laid out in part 1, we would proceed immediately to create a diff to update the event IDs, the new code will dig a little deeper though and will examine further if both attribute values are either EventCallback structs or MulticastDelegate instances. If they are, it examines for closure equality of the delegates by calling either CallbackEquals or DelegateEquals on a static class called EventHandlerDelegateWithClosuresComparer, that will do the actual comparison.

When the closures are deemed the same, hence the delegates are the same, we update the list of event handlers inside the renderer by calling ReplaceEventCallBackForEventHandlerForId with the ID and new callback or ReplaceDelegateForEventHandlerForId with the ID and new delegate respectively.

These two methods on the renderer are very straightforward and listed below:

With the diff builder and renderer adapted we turn to the implementation of EventHandlerDelegateWithClosuresComparer as the final piece of the puzzle that we have to put together.

A first naive implementation

Lets start by revisiting the compiler generated class that represents the closure from part 1:

We can define four conditions that all need to be met by comparing two delegates pointing to different instances of this class.

  • The class needs to be compiler generated.
  • The types need to be equal.
  • All public fields need to be equal.
  • The method the delegate points to needs to be the same method.

A first implementation of our EventHandlerDelegateWithClosuresComparer does exactly that:

DelegateEquals compares first using normal equality. Equal delegates are the same anyway, so return early (the internal implementation just does two pointer comparisons and is extremely fast). Then it makes sure that the delegates point with their target to instances (no static delegates).

At this stage, we need to get the types using reflection. When we have the types we can make sure that the types are the same, that both delegates point to the same method and the CompilerGeneratedAttribute is on the type.

Finally we loop all the public fields and make sure that they are all the same. This code indeed helps us with the on-change event callback case. The message sizes now remain constant (346 bytes) for every added row:

Success so it seems. Or is there still something we have not thought of? We’ll find out in part 3 of this series.