3

I've been trying to use JS's XMLHttpRequest Class for file uploading. I initially tried something like this:

const file = thisFunctionReturnsAFileObject();

const request = new XMLHttpRequest();
request.open('POST', '/upload-file');

const rawFileData = await file.arrayBuffer();

request.send(rawFileData);

The above code works (yay!), and sends the raw binary data of the file to my server.

However...... It uses a TON of memory (because the whole file gets stored in memory, and JS isn't particulary memory friendly)... I found out that on my machine (16GB RAM), I couldn't send files larger than ~100MB, because JS would allocate too much memory, and the Chrome tab would crash with a SIGILL code.


So, I thought it would be a good idea to use ReadableStreams here. It has good enough browser compatibility in my case (https://caniuse.com/#search=ReadableStream) and my TypeScript compiler told me that request.send(...) supports ReadableStreams (I later came to the conclusion that this is false). I ended up with code like this:

const file = thisFunctionReturnsAFileObject();

const request = new XMLHttpRequest();
request.open('POST', '/upload-file');

const fileStream = file.stream();

request.send(fileStream);

But my TypeScript compiler betrayed me (which hurt) and I recieved "[object ReadableStream]" on my server ಠ_ಠ.

I still haven't explored the above method too much, so I'm not sure if there might be a way to do this. I'd also appreciate help on this very much!


Splitting the request in chunk would be an optimal solution, since once a chunk has been sent, we can remove it from memory, before the whole request has even been recieved.

I have searched and searched, but haven't found a way to do this yet (which is why I'm here...). Something like this in pseudocode would be optimal:

const file = thisFunctionReturnsAFileObject();

const request = new XMLHttpRequest();
request.open('POST', '/upload-file');

const fileStream = file.stream();
const fileStreamReader = fileStream.getReader();

const sendNextChunk = async () => {
    const chunk = await fileStreamReader.read();

    if (!chunk.done) { // chunk.done implies that there is no more data to be read
        request.writeToBody(chunk.value); // chunk.value is a Uint8Array
    } else {
        request.end();
        break;
    }
}

sendNextChunk();

I'd like to expect this code to send the request in chunks and end the request when all chunks are sent.


The most helpful resource I tried, but didn't work:

Method for streaming data from browser to server via HTTP

Didn't work because:

  • I need the solution to work in a single request
  • I can't use RTCDataChannel, it must be in a plain HTTP request (is there an other way to do this than XMLHttpRequest?)
  • I need it to work in modern Chrome/Firefox/Edge etc. (no IE support is fine)

Edit: I don't want to use multipart-form (FormData Class). I want to send actual binary data read from the filestream in chunks.

user2864740
  • 60,010
  • 15
  • 145
  • 220
  • The root issue sounds like a bug, you should dig there rather than trying to find a hack-around. You should definitely be able to post data bigger than 100MB. Can you show how you tried to do this and provide an [mcve] showing how the browser crashes. One thing about the first code block, you don't need to load the File's data in memory, just do `xhr.send( file )` directly. – Kaiido Jul 12 '20 at 11:20
  • 1
    @Kaiido there is already a minimal reproducible example in the question (first code block). If the file length is larger than ~100MB, Chrome crashes and the Chrome task manager shows more than 400MB allocated memory to the tab. Also, I need to do some manipulation, so I can't use ``xhr.send(file)``. As I specified, I want to send *raw data* in chunks, not a File instance. EDIT: https://imgur.com/a/Fy9nmyK, example – Iannis de Zwart Jul 12 '20 at 12:13
  • 1
    @Kaiido. I know what I am doing. I think ``thisFunctionReturnsAFileObject`` is very descriptive. In the image example I show data that is *bigger* than 100MB (as I specified). 1E9 Bytes *is* bigger than 100MB. There is nothing wrong with the File object I provide, there is actually nothing wrong with the function at all (as I specified "the code above works"). I am not searching for the cause nor solution for the SIGILL error. I know and specified that it is because JS allocates too much memory. – Iannis de Zwart Jul 12 '20 at 13:31
  • ... And I know that you can send files way bigger than 100MB (even your 1GB file should work without problem if you were doing things the right way). I also know that sending a file through xhr.send should not put the whole file in memory and that the browser will actually already "stream" it. So I also know that you are doing something wrong, that chunking the file yourself or streaming it yourself won't help you. What I don't know for sure is everything that you are doing wrong, even though I already pointed two obvious errors which will make your file stored thrice in memory for no reasons. – Kaiido Jul 12 '20 at 14:22
  • 1
    It seems the OPs point is being missed.. they are asking how to perform a steaming send operation over the _entire_ process. 100MB or 1000MB is irrelevant, excepting as such hits different browser or resource limits. – user2864740 Jul 13 '20 at 02:07
  • @user2864740, no they have a static file, are facing a bug and **thought** that chunking or streaming would workaround that bug. And as I found out, the actual limit is 256MB, which is different than 100MB. So yes it is more thzn 100MB, they didn't lie but it's as useful to say that as saying "more than 0byte" – Kaiido Jul 13 '20 at 02:09
  • 1
    “The above code works (yay!), and sends the raw binary data of the file (of a small size) to my server.” - which seems like ‘not a bug’, unless one can point to a defining resource that File.arrayBuffer is _defined_ to return a file-backed ArrayBuffer (and thus not be duplicated in memory). I’d be happy for a reference on _guranteed_ standard behavior in this case, as optimizations are an implementation detail if not otherwise specified. – user2864740 Jul 13 '20 at 02:18
  • 1
    Likewise, if the bug is a specific memory limit it doesn’t apply to O(N) in the heart of the question. The limit might as well be OS memory allocations - the size is just different. Works on 200MB, okay.. 1000MB? 16GB? More? Without steaming the “bug” only changes where the limiting factor is. – user2864740 Jul 13 '20 at 02:23
  • @user2864740 "I couldn't send files larger than ~100MB, because JS would allocate too much memory, and the Chrome tab would crash with a SIGILL code." How is a crash not a bug? And file.arrayBuffer() is not the culprit here, `xhr.send( arraybuffer )` is. – Kaiido Jul 13 '20 at 02:24
  • 1
    See above. Being the lowest limit does not preclude the purpose of steaming. Conceding that it is an unnecessary implementation limit (ie. bug) does nothing to change such. – user2864740 Jul 13 '20 at 02:24
  • No that's not true, as I demonstrated in my answer, the "limit" is an hard limit that shouldn't be there, xhr.send is supposed to already stream the sent data. There is no limit to what xhr can send, and they would face the one of the device memory before the xhr.send. – Kaiido Jul 13 '20 at 02:26
  • 1
    Great. They face a limit on device memory - it’s the _same issue_ without streaming, only eliminating a known hard-limit. How would sending a 16GB file work? What about a 1GB file when there are many competing applications or a system with much lower memory available? N (the point of steaming) has not been limited to an upper-bound; or when one doesn’t want to needlessly use shared system resources. This is not to say that there isn’t a bug (point conceded), but rather to claim the desire to stream is in error and “a hack-around” is missing the purpose of streaming entirely. – user2864740 Jul 13 '20 at 02:38
  • @user2864740 But they would face that limit **before** even trying to send that data... As I said in my answer, streaming a static file (which OP is dealing with) is useless. Sure, being able to stream data is useful on the general sense, for instance to send data that is recorded from a MediaStream, or simply generated as a stream, but that's not what this question is about. This question is an XY problem with its X being caused by a bug in Chrome's xhr.send. – Kaiido Jul 13 '20 at 03:29
  • @Kaiido why is it useless to stream a static file? The file is going to be split up in parts of max 64KB (at least, in the latest Chrome). *this is exactly what I want!* The idea of streaming is to send a request with headers, keep it open, and send chunks (of 64KB) one after each other, therefore only occupying 64KB of memory to deal with the file (in theory). After everything has been sent (and in my case this could be multiple files including file meta in JSON), close the request. I couldn't find a way to do this in JS, so I asked. Please read questions if you're willing to help someone – Iannis de Zwart Jul 13 '20 at 09:58
  • @Iannís it is useless because that's already what xhr.send(file) does. To send this `file` to the server, the browser will only read it by chunks and won't copy the data again in memory. With a static File doing fetch( { body : file.stream() } ) is the same as fetch( { body: file } ) directly, except that the latter works since IE10. – Kaiido Jul 13 '20 at 10:08
  • But I've specified that I will need to do some manipulation with the raw data, you're just telling me that *the thing I need to get done* is stupid. – Iannis de Zwart Jul 13 '20 at 10:59
  • Not at all! Where did I say that anything is stupid? I'm saying that you absolutely don't need to wait for ReadableStreams to be sendables, or any other hack-around, because what you are asking for is already implemented in the browser itself as the default action when doing the simple `xhr.send( file )`. In other words I'm telling you that you are asking us for something that you don't need, and instead, **in order to help you the best I can**, I'm telling you, since my first comment here, to use the one method that does exactly what you want to do. – Kaiido Jul 13 '20 at 11:34
  • I mean, since yesterday, did you even try doing what I've been suggesting? Do you still face the same issue? – Kaiido Jul 13 '20 at 11:37
  • I'm trying to make it work with WebSockets now, since it seems like there is no current other way of streaming data over http – Iannis de Zwart Jul 13 '20 at 11:51

2 Answers2

2

You can't do this with XHR afaik. But the more modern fetch API does support passing a ReadableStream for the request body. In your case:

const file = thisFunctionReturnsAFileObject();

const response = await fetch('/upload-file', {
  method: 'POST',
  body: file.stream(),
});

However, I'm not certain whether this will actually use chunked encoding.

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • 2
    the fetch ``Request`` body does not actually support ``ReadableStream`` (tested this in the latest Chrome and Firefox). It's the same problem as in the second code block I published – Iannis de Zwart Jul 12 '20 at 17:32
  • 1
    @Iannís Apparently [it's going to be released with Chrome 85](https://www.chromestatus.com/feature/5274139738767360) – Bergi Jul 12 '20 at 19:40
  • 1
    Should be noted that even on latest Canary (86), it's still not available, not even behind a flag. Also, per the current specs, `fetch( url, { method: "POST", body: file.stream() } )` does the exact same thing as `fetch( url, { method: "POST", body: file } )` directly. Here is [`Blob.stream()` algorithm](https://w3c.github.io/FileAPI/#blob-get-stream), [here is extract body](https://fetch.spec.whatwg.org/#concept-bodyinit-extract). Your answer might be useful for someone else, but your example should probably be edited a bit. – Kaiido Jul 14 '20 at 01:28
  • 1
    As per https://developer.mozilla.org/en-US/docs/Web/API/fetch#body and the [table](https://developer.mozilla.org/en-US/docs/Web/API/Request#browser_compatibility) linked from there, the feature _"Send `ReadableStream` in request body"_ got **released in Chrome 105**. Current Firefox and Safari do not support it. The decision on chunked encoding (https://github.com/whatwg/fetch/issues/966) was to not support it; thus **streaming upload bodies require HTTP >= 2** ([spec commit](https://github.com/whatwg/fetch/commit/7149b1536f3631143bf8d2761d2760536d7aafd9)). – nh2 Apr 15 '23 at 14:18
1

You are facing a Chrome bug where they do set an hard-limit of 256MB to the size of the ArrayBuffer that can be sent.

But anyway, sending an ArrayBuffer will create a copy of the data, so you should rather send your data as a File directly, since this will only read the File exactly like you wanted it to be, as a stream by small chunks.

So taking your first code block that would give

const file = thisFunctionReturnsAFileObject();

const request = new XMLHttpRequest();
request.open('POST', '/upload-file');

request.send(file);

Ans this will work in Chrome too, even with few Gigs files. The only limit you would face here would be before, when you'd do whatever processing you are doing on that File.


Regarding posting ReadableStreams, this will eventually come, but as of today July the 13th of 2020, only Chrome has started working on its implementation, and we web-devs still can't play with it, and specs are still having hard times to come with something stable.
But it's not a problem for you, since you would not win anything doing so anyway. Posting a ReadableStream made from a static File is useless, both fetch and xhr will do this internally already.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Most browsers will not send the file and will instead do the post with a body length of 0 when you do request.send(file). – Steve Owens May 25 '21 at 18:55
  • @SteveOwensSte what? XHR.send(file) [is supported](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/send#browser_compatibility) by everyone since IE10 (look at the "blob as send param" section of the table). If it doesn't work for you, it's because you messed up. – Kaiido May 25 '21 at 23:19