Generating and efficiently exporting a file in a Blazor WebAssembly application

 
 
  • Gérald Barré

In a Blazor WebAssembly application, I needed to export data to a file. In a web browser, you cannot write the file directly to the file system (not exactly true anymore). Instead, you need to create a valid URL, create a <a> element, and trigger a click on it. There are 2 ways to create a valid URL:

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

So, the best solution is to create a Blob, so there is no limitation in size. Using WebAssembly this is also much faster!

Let's create the razor page with 2 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);
    }
}

Now we have to implement theHere's the corresponding JavaScript function BlazorDownloadFile. ASP.NET Core Blazor 6 introduce a new feature to make it easier to implement this function.

#.NET 6: BlazorDownloadFile JS function

Blazor now supports optimized byte-array interop, which avoids encoding and decoding byte-arrays into Base64 and facilitates 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 pretty well! The file is generated and the user can download it. However, when the file is large (a few MB), it takes seconds before downloading the file. This is because converting the base64 string to the Uint8Array is slow. Let's see how we can improve that by using low-level methods in Blazor WebAssembly!

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

This conversion looks useless as a byte[] is the same as an Uint8Array. So, there may be a way to avoid the conversion. In the previous post about optimizing JS Interop in a Blazor WebAssembly application, I explained how to use the Blazor WebAssembly specific methods to call a JS function. One of them is to call the function without marshaling the parameters. In this case, mono doesn't convert the parameters to the JS native types. In this case, the values are just handled you need to manipulate.

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. However, you need to marshal the parameter manually. When using unmarshalled calls, the parameter values are just handled. To convert these handles 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 a little harder to understand, and the functions in BINDING or Blazor.platform are not documented. This means it can break in a future version of Blazor WebAssembly. However, the performance improvement is so huge that it is worth it! Using the InvokeAsync method, it takes a few seconds and it increases linearly with the file size. This method is almost instant and the performance is quite constant whatever the file size is.

#Additional resources

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

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub