Twitter About Home

File uploads with Blazor

A component to simplify working with user-supplied files

Published Sep 13, 2019

For a long time we’ve expected that we’d add a built-in “file input” feature to Blazor. This would let users pick local files and supply them to your application. Possible uses cases include:

  • You want to upload and store those files on a server
  • Or, you want to read and import some data from them
  • Or, you want to present an editor UI for the file

It applies equally to client-side or server-side Blazor. In client-side Blazor, you’re loading the file into the .NET application’s memory, which can then edit it locally or could make an HTTP request to transfer it to some backend server. In server-side Blazor, your code is already running on the server, but you still want to be able to read files picked by the user.

Existing options

There are already several open-source or third-party libraries in this area (example from Rémi Bourgarel, example from SyncFusion). It’s especially worth mentioning Tewr’s BlazorFileReader library, which does an excellent job and is quite similar to what I’m proposing in this post.

What I want out of a great file input component is:

  • Does not require setting up a separate server-side API endpoint. The file data needs to get into Blazor via the existing JS interop mechanism.
  • Provides access to the file data as a regular .NET Stream, so other code can handle it just the same as if it were a normal file on disk.
    • This must literally stream the content into the .NET process, since we don’t want to depend on loading it all into memory at once.
  • Works independently of SignalR message size limits and file API buffer sizes
    • This is what several of the existing options don’t manage. By default, SignalR imposes a limit of 32KB for incoming messages, and .NET APIs like Stream.CopyToAsync use much larger internal buffers, so the streaming logic needs to work with this and not require reconfiguration.
  • … while achieving near-native-HTTP transfer speeds
    • This is the hardest bit, not addressed by existing solutions as far as I know. There are simple ways to satisfy all the requirements above if you’re willing to accept greatly reduced upload rates. I’ll talk about the challenges and solutions later in this post.

Introducing <InputFile>

As a possible starting point for a future built-in feature, I’ve published a NuGet package called BlazorInputFile (source on GitHub), which provides a component called <InputFile>.

Its features include uploading a single file (sample source):

Single file upload

Or, multi-file upload and progress notifications (sample source):

Multiple file upload

Or, custom UI including drag-drop support (sample source):

Drag-drop viewer

Installation

First, be sure you’re on the latest 3.0.0-preview9 version of Blazor, or newer if you’re reading this from the future. You can use either server-side or client-side (WebAssembly).

Add a dependency on the BlazorInputFile package in your .csproj:

<ItemGroup>
    <PackageReference Include="BlazorInputFile" Version="0.1.0-preview" />
</ItemGroup>

For server-side Blazor

Reference the package’s JavaScript file by editing your _Host.cshtml to add the following. It can go in the <body> or in <head>, wherever you want:

<script src="_content/BlazorInputFile/inputfile.js"></script>

Now in your _Imports.razor, add:

@using BlazorInputFile

Temporary caveat: Until .NET Core 3.0 ships, there’s a bug you need to work around if you’re hosting on IIS Express. Thanks to Tewr for originally reporting this.

That’s it - you’re ready to go! Move on to usage instructions.

For client-side Blazor

Due to a bug that we’ll fix before client-side Blazor is shipped, you can’t just reference inputfile.js from _content. Instead you’ll have to manually copy the contents of inputfile.js from GitHub into a file in your project. For example, put it directly into wwwroot, and then add the following reference into your index.html:

<script src="inputfile.js"></script>

This manual file-copying step for client-side Blazor will be fixed and eliminated soon. Finally, in your _Imports.razor, add:

@using BlazorInputFile

Usage

In one of your components, you can now add an <InputFile> component. You’ll also want to add an event handler for OnChange so you can respond when files are picked:

<InputFile OnChange="HandleFileSelected" />

@code {
    void HandleFileSelected(IFileListEntry[] files)
    {
        // Do something with the files, e.g., read them
    }
}

In this case, we’re only allowing single-file selection, so the files array will have either zero or one entry. You can read metadata about the file even before you actually transfer the contents of the file anywhere:

<InputFile OnChange="HandleFileSelected" />

@if (file != null)
{
    <p>Name: @file.Name</p>
    <p>Size in bytes: @file.Size</p>
    <p>Last modified date: @file.LastModified.ToShortDateString()</p>
    <p>Content type (not always supplied by the browser): @file.Type</p>
}

@code {
    IFileListEntry file;

    void HandleFileSelected(IFileListEntry[] files)
    {
        file = files.FirstOrDefault();
    }
}

You can read data from the file either immediately on selection, or later (e.g., when the user clicks an ‘upload’ button). To read the data, just access file.Data which is a Stream. For example, to count the number of lines in a text file:

<InputFile OnChange="HandleFileSelected" />

@if (file != null)
{
    <p>Number of lines read: @numLines</p>
    <button @onclick="CountLines">Count</button>
}

@code {
    int numLines;
    IFileListEntry file;

    void HandleFileSelected(IFileListEntry[] files)
    {
        file = files.FirstOrDefault();
    }

    async Task CountLines()
    {
        numLines = 0;
        using (var reader = new System.IO.StreamReader(file.Data))
        {
            while (await reader.ReadLineAsync() != null)
            {
                numLines++;
            }
        }
    }
}

Important: You can only use asynchronous APIs on this stream (e.g., ReadLineAsync, not ReadLine), because the data has to be transferred over the network.

If you want to support multiple file selection, just add a multiple attribute:

<InputFile multiple OnChange="HandleFileSelected" />

… and now your event handler will receive a IFileListEntry[] that can contain multiple entries.

Implementation notes

For client-side Blazor (i.e., on WebAssembly), the data transfer between the browser’s JavaScript APIs and .NET is very simple and near-instant, since it’s all running locally. <InputFile> uses Blazor’s low-level unmarshalled interop APIs to copy the requested chunks of the binary data directly into .NET memory without needing any serialization.

For server-side Blazor (i.e., via SignalR), there’s a lot more going on. We have to fetch chunks via IJSRuntime which only allows JSON-serializable data, and imposes a limit on the size of each returned chunk. By default, SignalR’s maximum message size is 32KB.

Bandwidth, latency, and security

Other libraries approaching this problem have required users to configure a SignalR message size greater than whatever maximum buffer size is used by the I/O APIs you’re using (e.g., the default for CopyToAsync is about 128KB). As well as being inconvenient for developers, this still leaves a serious performance issue. Since each chunk is being fetched sequentially, the maximum transfer rate becomes limited not only by network bandwidth, but also by network latency. If your round-trip ping time from client to server is L (e.g., L = 0.2 sec), and your SignalR message size is S (e.g., S = 32KB by default), then the tranfer rate cannot exceed S/L (in this example, 32/0.2 = 160 KB/sec), even if you have a terrabit network connection.

Additionally, even if your SignalR message size is arbitrarily large, the I/O APIs you’re using might themselves use smaller buffers. I often see code that reads data from streams in 1 KB chunks. For L=0.2, that would result in a maximum transfer rate of just 5KB/sec! It’s not enough just to wrap a BufferedStream around the source, since then you wouldn’t get progress notifications as often.

My goal here is to get much closer to using your full network bandwidth. The approach this library uses is parallelism: the .NET side sets up a data structure that can hold many chunks (default total size is around 1MB), and asks the JS side to populate segments within it via a lot of concurrent interop calls. The parallelism amortises the latency, so the bottleneck ends up being your actual network bandwidth, which is what we want. But to maximise UI responsiveness, the I/O operations don’t wait for that whole ~1MB structure to be filled - they receive completion notifications as each smaller segment comes in from the JS side.

Also, for security reasons, we don’t want to trust the JS-side code to send us as much data as it wants. That would let misbehaving clients occupy as much .NET memory as they want. So, all the transfer operations are initiated from the .NET side, and we can be sure to make full use of the allowed memory but no more.

The net result of all this is that developers don’t have to reconfigure SignalR or specify custom buffer sizes when making file API calls. It all just works efficiently and safely by default.

Perf numbers

For client-side Blazor (WebAssembly), the data transfer speed is limited only by your CPU. You can load a 10 MB file into .NET memory almost instantly. On my machine, loading a 100 MB file into a .NET byte array takes about a second.

To test with server-side Blazor, I deployed an application to a server about 4000 miles from me (so there’s plenty of latency), and tried uploading a 20MB file.

  • With a plain native HTTP upload on a fast office connection, the upload time was around 9 to 10 seconds. On the same network, transferring via <InputFile> took around 12 to 14 seconds.
  • For the same 20MB file but over a slower cafe wifi connection, the native HTTP upload times were between 21 and 26 seconds. On the same network, transferring via <InputFile> took between 23 and 27 seconds.

It’s entirely expected that <InputFile> takes ~30% longer than native HTTP uploads, because it has to base64 encode the data since IJSRuntime only allows JSON responses. For smaller files, e.g., under 5MB, it’s unlikely that users will perceive any difference, and the vast level of extra convenience offered by <InputFile> (e.g., no need to set up a separate API endpoint) makes this well worthwhile. We’ll also be able to eliminate the base64 penalty in the future by adding support for binary responses to JS interop calls, or expose SignalR’s built-in streaming mechanisms, at which point there should be no meaningful speed difference versus native HTTP uploads.

READ NEXT

Integrating FluentValidation with Blazor

An example of integrating a custom third-party validation system with Blazor's forms

Published Sep 4, 2019