Generating and efficiently exporting a file in a Blazor WebAssembly application

 
 
  • Gérald Barré

In a Blazor WebAssembly application, exporting data to a file requires some care. Browsers cannot write files directly to the file system (not exactly true anymore). Instead, you must create a valid URL, create an <a> element, and trigger a click on it. There are two ways to create a valid URL:

  • Using a base64 data URL (e.g. data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==), but there are a few limitations, including a file size cap (Common problems)
  • Using a Blob and URL.createObjectURL(blob)

The best solution is to create a Blob, which has no size limitation. In WebAssembly, this approach is also much faster!

Let's create the Razor page with two buttons to generate a file and download it:

Razor
@page "/"
@inject IJSRuntime JSRuntime

<button @onclick="DownloadBinary">Download binary file</button>
<button @onclick="DownloadText">Download text file</button>

@code{
    async Task DownloadBinary()
    {
        // Generate a file
        byte[] file = Enumerable.Range(0, 100).Select(value => (byte)value).ToArray();

        // Send the data to JS to actually download the file
        await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", "file.bin", "application/octet-stream", file);
    }

    async Task DownloadText()
    {
        // Generate a text file
        byte[] file = System.Text.Encoding.UTF8.GetBytes("Hello world!");
        await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", "file.txt", "text/plain", file);
    }
}

Here is the corresponding JavaScript function BlazorDownloadFile. .NET 6 introduced a new feature that makes this function more straightforward to implement.

#.NET 6: BlazorDownloadFile JS function

Blazor now supports optimized byte-array interop, which avoids encoding and decoding byte arrays into Base64, enabling a more efficient interop process. This applies to both Blazor Server and Blazor WebAssembly.

JavaScript
// Use it for .NET 6+
function BlazorDownloadFile(filename, contentType, content) {
    // Create the URL
    const file = new File([content], filename, { type: contentType });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = filename;
    a.target = "_self";
    a.click();

    // We don't need to keep the object URL, let's release the memory
    // On older versions of Safari, it seems you need to comment this line...
    URL.revokeObjectURL(exportUrl);
}

#NET Core 3.1 or .NET 5: BlazorDownloadFile JS function

JavaScript
// Use it for .NET Core 3.1 or .NET 5
function BlazorDownloadFile(filename, contentType, content) {
    // Blazor marshall byte[] to a base64 string, so we first need to convert
    // the string (content) to a Uint8Array to create the File
    const data = base64DecToArr(content);

    // Create the URL
    const file = new File([data], filename, { type: contentType });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = filename;
    a.target = "_self";
    a.click();

    // We don't need to keep the object URL, let's release the memory
    // On older versions of Safari, it seems you need to comment this line...
    URL.revokeObjectURL(exportUrl);
}

// Convert a base64 string to a Uint8Array. This is needed to create a blob object from the base64 string.
// The code comes from: https://developer.mozilla.org/fr/docs/Web/API/WindowBase64/D%C3%A9coder_encoder_en_base64
function b64ToUint6(nChr) {
  return nChr > 64 && nChr < 91 ? nChr - 65 : nChr > 96 && nChr < 123 ? nChr - 71 : nChr > 47 && nChr < 58 ? nChr + 4 : nChr === 43 ? 62 : nChr === 47 ? 63 : 0;
}

function base64DecToArr(sBase64, nBlocksSize) {
  var
    sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""),
    nInLen = sB64Enc.length,
    nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2,
    taBytes = new Uint8Array(nOutLen);

  for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
    nMod4 = nInIdx & 3;
    nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
    if (nMod4 === 3 || nInLen - nInIdx === 1) {
      for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
        taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
      }
      nUint24 = 0;
    }
  }
  return taBytes;
}

This works well. The file is generated and the user can download it. However, when the file is large (a few MB), the download can take several seconds. This delay occurs because converting the base64 string to a Uint8Array is slow. Let's see how to improve that using low-level methods in Blazor WebAssembly.

#Blazor WebAssembly optimization (.NET Core 3.1 and .NET 5)

This conversion is unnecessary since byte[] and Uint8Array represent the same data, so there should be a way to avoid it. In the previous post about optimizing JS Interop in a Blazor WebAssembly application, I explained how to use Blazor WebAssembly-specific methods to call a JS function. One approach is to call the function without marshaling the parameters. In that case, Mono passes the values as raw handles rather than converting them to native JS types.

C#
void DownloadBinaryOptim()
{
    byte[] file = Enumerable.Range(0, 100).Select(v => (byte)v).ToArray();
    string fileName = "file.bin";
    string contentType = "application/octet-stream";

    // Check if the IJSRuntime is the WebAssembly implementation of the JSRuntime
    if (JSRuntime is IJSUnmarshalledRuntime webAssemblyJSRuntime)
    {
        webAssemblyJSRuntime.InvokeUnmarshalled<string, string, byte[], bool>("BlazorDownloadFileFast", fileName, contentType, file);
    }
    else
    {
        // Fall back to the slow method if not in WebAssembly
        await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", fileName, contentType, file);
    }
}

The JS function is similar to the previous implementation, but you need to convert the parameters manually. With unmarshalled calls, parameter values arrive as raw handles. To convert them to actual JS types, you can use the functions provided by Mono and some Blazor helpers.

JavaScript
function BlazorDownloadFileFast(name, contentType, content) {
    // Convert the parameters to actual JS types
    const nameStr = BINDING.conv_string(name);
    const contentTypeStr = BINDING.conv_string(contentType);
    const contentArray = Blazor.platform.toUint8Array(content);

    // Create the URL
    const file = new File([contentArray], nameStr, { type: contentTypeStr });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = nameStr;
    a.target = "_self";
    a.click();

    // We don't need to keep the url, let's release the memory
    // On Safari it seems you need to comment this line... (please let me know if you know why)
    URL.revokeObjectURL(exportUrl);
}

Of course, the code is harder to understand, and the functions in BINDING or Blazor.platform are undocumented, meaning they could break in a future version of Blazor WebAssembly. However, the performance improvement is significant enough to justify the tradeoff. With the InvokeAsync method, execution takes several seconds and scales linearly with file size. This method is nearly instant, with performance remaining roughly constant regardless of file size.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?