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 application you cannot write the file directly to the file system. 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:

@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).Cast<byte>().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's the corresponding JS:

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 url, let's release the memory
    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 using low-level methods!

#Blazor WebAssembly optimization

This conversion looks useless as a byte[] is basically 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 marshalling the parameters. In this case mono doesn't convert the parameters to the JS native types. In this case, the values are just handles you need to manipulate.

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

    // Check if the IJSRuntime is the WebAssembly implementation of the JSRuntime
    if (JSRuntime is Microsoft.JSInterop.WebAssembly.WebAssemblyJSRuntime 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 handles. To convert these handles to actual JS types, you can use the functions provided by Mono and some Blazor helpers.

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
    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 methods is almost instant and the performance are 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