Detect when a browser receives a file download

Viewed 401679

I have a page that allows the user to download a dynamically-generated file. It takes a long time to generate, so I'd like to show a "waiting" indicator. The problem is, I can't figure out how to detect when the browser has received the file so that I can hide the indicator.

I'm requesting a hidden form, which POSTs to the server, and targets a hidden iframe for its results. This is, so I don't replace the entire browser window with the result. I listen for a "load" event on the iframe, hoping that it will fire when the download is complete.

I return a "Content-Disposition: attachment" header with the file, which causes the browser to show the "Save" dialog. But the browser doesn't fire a "load" event in the iframe.

One approach I tried is using a multi-part response. So it would send an empty HTML file, as well as the attached downloadable file.

For example:

Content-type: multipart/x-mixed-replace;boundary="abcde"

--abcde
Content-type: text/html

--abcde
Content-type: application/vnd.fdf
Content-Disposition: attachment; filename=foo.fdf

file-content
--abcde

This works in Firefox; it receives the empty HTML file, fires the "load" event, and then shows the "Save" dialog for the downloadable file. But it fails on Internet Explorer and Safari; Internet Explorer fires the "load" event, but it doesn't download the file, and Safari downloads the file (with the wrong name and content-type) and doesn't fire the "load" event.

A different approach might be to call to start the file creation, poll the server until it's ready, and then download the already-created file. But I'd rather avoid creating temporary files on the server.

What should I do?

24 Answers

The core problem is that the web browser does not have an event that fires when page navigation is cancelled but does have an event that fires when a page completes loading. Anything outside of a direct browser event is going to be a hack with pros and cons.

There are four known approaches to dealing with detecting when a browser download starts:

  1. Call fetch(), retrieve the entire response, attach an a tag with a download attribute, and trigger a click event. Modern web browsers will then offer the user the option to save the already retrieved file. There are several downsides with this approach:
  • The entire data blob is stored in RAM, so if the file is large, it will consume that much RAM. For small files, this probably isn't a deal breaker.
  • The user has to wait for the entire file to download before they can save it. They also can't leave the page until it completes.
  • The built-in web browser file downloader is not used.
  • A cross-domain fetch will probably fail unless CORS headers are set.
  1. Use an iframe + a server-side cookie. The iframe fires a load event if a page loads in the iframe instead of starting a download but it does not fire any events if the download starts. Setting a cookie with the web server can then be detected by JavaScript in a loop. There are several downsides with this approach:
  • The server and client have to work in concert. The server has to set a cookie. The client has to detect the cookie.
  • Cross-domain requests won't be able to set the cookie.
  • There are limits to how many cookies can be set per domain.
  • Can't send custom HTTP headers.
  1. Use an iframe with URL redirection. The iframe starts a request and once the server has prepared the file, it dumps a HTML document that performs a meta refresh to a new URL, which triggers the download 1 second later. The load event on the iframe happens when the HTML document loads. There are several downsides with this approach:
  • The server has to maintain storage for the content being downloaded. Requires a cron job or similar to regularly clean up the directory.
  • The server has to dump out special HTML content when the file is ready.
  • The client has to guess as to when the iframe has actually made the second request to the server and when the download has actually started before removing the iframe from the DOM. This could be overcome by just leaving the iframe in the DOM.
  • Can't send custom HTTP headers.
  1. Use an iframe + XHR. The iframe triggers the download request. As soon as the request is made via the iframe, an identical request via XHR is made. If the load event on the iframe fires, an error has occurred, abort the XHR request, and remove the iframe. If a XHR progress event fires, then downloading has probably started in the iframe, abort the XHR request, wait a few seconds, and then remove the iframe. This allows for larger files to be downloaded without relying on a server-side cookie. There are several downsides with this approach:
  • There are two separate requests made for the same information. The server can distinguish the XHR from the iframe by checking the incoming headers.
  • A cross-domain XHR request will probably fail unless CORS headers are set. However, the browser won't know if CORS is allowed or not until the server sends back the HTTP headers. If the server waits to send headers until the file data is ready, the XHR can roughly detect when the iframe has started to download even without CORS.
  • The client has to guess as to when the download has actually started to remove the iframe from the DOM. This could be overcome by just leaving the iframe in the DOM.
  • Can't send custom headers on the iframe.

Without an appropriate built-in web browser event, there aren't any perfect solutions here. However, one of the four methods above will likely be a better fit than the others depending on your use-case.

Whenever possible, stream responses to the client on the fly instead of generating everything first on the server and then sending the response. Various file formats can be streamed such as CSV, JSON, XML, ZIP, etc. It really depends on finding a library that supports streaming content. When streaming the response as soon as the request starts, detecting the start of the download won't matter as much because it will start almost right away.

Another option is to just output the download headers up front instead of waiting for all of the content to be generated first. Then generate the content and finally start sending to the client. The user's built-in downloader will patiently wait until the data starts arriving. The downside is that the underlying network connection could timeout waiting for data to start flowing (either on the client or server side).

I faced the same problem with that configuration:

My solution with a cookie:

Client side:

When submitting your form, call your JavaScript function to hide your page and load your waiting spinner

function loadWaitingSpinner() {
    ... hide your page and show your spinner ...
}

Then, call a function that will check every 500 ms whether a cookie is coming from server.

function checkCookie() {
    var verif = setInterval(isWaitingCookie, 500, verif);
}

If the cookie is found, stop checking every 500 ms, expire the cookie and call your function to come back to your page and remove the waiting spinner (removeWaitingSpinner()). It is important to expire the cookie if you want to be able to download another file again!

function isWaitingCookie(verif) {
    var loadState = getCookie("waitingCookie");
    if (loadState == "done") {
        clearInterval(verif);
        document.cookie = "attenteCookie=done; expires=Tue, 31 Dec 1985 21:00:00 UTC;";
        removeWaitingSpinner();
    }
}

function getCookie(cookieName) {
    var name = cookieName + "=";
    var cookies = document.cookie
    var cs = cookies.split(';');
    for (var i = 0; i < cs.length; i++) {
        var c = cs[i];
        while(c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}

function removeWaitingSpinner() {
    ... come back to your page and remove your spinner ...
}

Server side:

At the end of your server process, add a cookie to the response. That cookie will be sent to the client when your file will be ready for download.

Cookie waitCookie = new Cookie("waitingCookie", "done");
response.addCookie(waitCookie);

A solution from elsewhere that worked:

/**
 *  download file, show modal
 *
 * @param uri link
 * @param name file name
 */
function downloadURI(uri, name) {
// <------------------------------------------       Do something (show loading)
    fetch(uri)
        .then(resp => resp.blob())
        .then(blob => {
            const url = window.URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.style.display = 'none';
            a.href = url;
            // the filename you want
            a.download = name;
            document.body.appendChild(a);
            a.click();
            window.URL.revokeObjectURL(url);
            // <----------------------------------------  Detect here (hide loading)
            alert('File detected');
        })
        .catch(() => alert('An error sorry'));
}

You can use it:

downloadURI("www.linkToFile.com", "file.name");

When the user triggers the generation of the file, you could simply assign a unique ID to that "download", and send the user to a page which refreshes (or checks with AJAX) every few seconds. Once the file is finished, save it under that same unique ID and...

  • If the file is ready, do the download.
  • If the file is not ready, show the progress.

Then you can skip the whole iframe/waiting/browserwindow mess, yet have a really elegant solution.

If you don't want to generate and store the file on the server, are you willing to store the status, e.g. file-in-progress, file-complete? Your "waiting" page could poll the server to know when the file generation is complete. You wouldn't know for sure that the browser started the download but you'd have some confidence.

In my experience, there are two ways to handle this:

  1. Set a short-lived cookie on the download, and have JavaScript continually check for its existence. Only real issue is getting the cookie lifetime right - too short and the JavaScript can miss it, too long and it might cancel the download screens for other downloads. Using JavaScript to remove the cookie upon discovery usually fixes this.
  2. Download the file using fetch/XHR. Not only do you know exactly when the file download finishes, if you use XHR you can use progress events to show a progress bar! Save the resulting blob with msSaveBlob in Internet Explorer or Edge and a download link (like this one) in Firefox and Chrome. The problem with this method is that iOS Safari doesn't seem to handle downloading blobs right - you can convert the blob into a data URL with a FileReader and open that in a new window, but that's opening the file, not saving it.

I just had this exact same problem. My solution was to use temporary files since I was generating a bunch of temporary files already. The form is submitted with:

var microBox = {
    show : function(content) {
        $(document.body).append('<div id="microBox_overlay"></div><div id="microBox_window"><div id="microBox_frame"><div id="microBox">' +
        content + '</div></div></div>');
        return $('#microBox_overlay');
    },

    close : function() {
        $('#microBox_overlay').remove();
        $('#microBox_window').remove();
    }
};

$.fn.bgForm = function(content, callback) {
    // Create an iframe as target of form submit
    var id = 'bgForm' + (new Date().getTime());
    var $iframe = $('<iframe id="' + id + '" name="' + id + '" style="display: none;" src="about:blank"></iframe>')
        .appendTo(document.body);
    var $form = this;
    // Submittal to an iframe target prevents page refresh
    $form.attr('target', id);
    // The first load event is called when about:blank is loaded
    $iframe.one('load', function() {
        // Attach listener to load events that occur after successful form submittal
        $iframe.load(function() {
            microBox.close();
            if (typeof(callback) == 'function') {
                var iframe = $iframe[0];
                var doc = iframe.contentWindow.document;
                var data = doc.body.innerHTML;
                callback(data);
            }
        });
    });

    this.submit(function() {
        microBox.show(content);
    });

    return this;
};

$('#myForm').bgForm('Please wait...');

At the end of the script that generates the file I have:

header('Refresh: 0;url=fetch.php?token=' . $token);
echo '<html></html>';

This will cause the load event on the iframe to be fired. Then the wait message is closed and the file download will then start. It was tested on Internet Explorer 7 and Firefox.

If you have downloaded a file, which is saved, as opposed to being in the document, there isn't any way to determine when the download is complete, since it is not in the scope of the current document, but a separate process in the browser.

You can rely on the browser's cache and trigger a second download of the same file when the file is loaded to the cache.

$('#link').click(function(e) {
    e.preventDefault();

    var url = $(this).attr('href');
    var request = new XMLHttpRequest();
    request.responseType = "blob";
    request.open("GET", url);

    var self = this;
    request.onreadystatechange = function () {
        if (request.readyState === 4) {
            var file = $(self).data('file');
            var anchor = document.createElement('a');
            anchor.download = file;
            console.log(file);
            console.log(request);
            anchor.href = window.URL.createObjectURL(request.response);
            anchor.click();
            console.log('Completed. Download window popped up.');
        }
    };
    request.send();
});

This Java/Spring example detects the end of a download, at which point it hides the "Loading..." indicator.

Approach: On the JavaScript side, set a cookie with a maximum expiration age of 2 minutes, and poll every second for cookie expiration. Then the server-side overrides this cookie with an earlier expiration age -- the completion of the server process. As soon as the cookie expiration is detected in the JavaScript polling, "Loading..." is hidden.

JavaScript Side

function buttonClick() { // Suppose this is the handler for the button that starts
    $("#loadingProgressOverlay").show();  // Show loading animation
    startDownloadChecker("loadingProgressOverlay", 120);
    // Here you launch the download URL...
    window.location.href = "myapp.com/myapp/download";
}

// This JavaScript function detects the end of a download.
// It does timed polling for a non-expired Cookie, initially set on the
// client-side with a default max age of 2 min.,
// but then overridden on the server-side with an *earlier* expiration age
// (the completion of the server operation) and sent in the response.
// Either the JavaScript timer detects the expired cookie earlier than 2 min.
// (coming from the server), or the initial JavaScript-created cookie expires after 2 min.
function startDownloadChecker(imageId, timeout) {

    var cookieName = "ServerProcessCompleteChecker";  // Name of the cookie which is set and later overridden on the server
    var downloadTimer = 0;  // Reference to the timer object

    // The cookie is initially set on the client-side with a specified default timeout age (2 min. in our application)
    // It will be overridden on the server side with a new (earlier) expiration age (the completion of the server operation),
    // or auto-expire after 2 min.
    setCookie(cookieName, 0, timeout);

    // Set a timer to check for the cookie every second
    downloadTimer = window.setInterval(function () {

        var cookie = getCookie(cookieName);

        // If cookie expired (NOTE: this is equivalent to cookie "doesn't exist"), then clear "Loading..." and stop polling
        if ((typeof cookie === 'undefined')) {
            $("#" + imageId).hide();
            window.clearInterval(downloadTimer);
        }

    }, 1000); // Every second
}

// These are helper JavaScript functions for setting and retrieving a Cookie
function setCookie(name, value, expiresInSeconds) {
    var exdate = new Date();
    exdate.setTime(exdate.getTime() + expiresInSeconds * 1000);
    var c_value = escape(value) + ((expiresInSeconds == null) ? "" : "; expires=" + exdate.toUTCString());
    document.cookie = name + "=" + c_value + '; path=/';
}

function getCookie(name) {
    var parts = document.cookie.split(name + "=");
    if (parts.length == 2 ) {
        return parts.pop().split(";").shift();
    }
}

Java/Spring Server Side

    @RequestMapping("/download")
    public String download(HttpServletRequest request, HttpServletResponse response) throws Exception {
        //... Some logic for downloading, returning a result ...

        // Create a Cookie that will override the JavaScript-created
        // Max-Age-2min Cookie with an earlier expiration (same name)
        Cookie myCookie = new Cookie("ServerProcessCompleteChecker", "-1");
        myCookie.setMaxAge(0); // This is immediate expiration, but can also
                               // add +3 seconds for any flushing concerns
        myCookie.setPath("/");
        response.addCookie(myCookie);
        //... -- presumably the download is writing to the Output Stream...
        return null;
}

PrimeFaces uses cookie polling, too.

monitorDownload():

    monitorDownload: function(start, complete, monitorKey) {
        if(this.cookiesEnabled()) {
            if(start) {
                start();
            }

            var cookieName = monitorKey ? 'primefaces.download_' + monitorKey : 'primefaces.download';
            window.downloadMonitor = setInterval(function() {
                var downloadComplete = PrimeFaces.getCookie(cookieName);

                if(downloadComplete === 'true') {
                    if(complete) {
                        complete();
                    }
                    clearInterval(window.downloadMonitor);
                    PrimeFaces.setCookie(cookieName, null);
                }
            }, 1000);
        }
    },

I have updated the below reference code. Add a proper download URL link and try this out.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <style type="text/css">
            body {
                padding: 0;
                margin: 0;
            }

            svg:not(:root) {
                display: block;
            }

            .playable-code {
                background-color: #F4F7F8;
                border: none;
                border-left: 6px solid #558ABB;
                border-width: medium medium medium 6px;
                color: #4D4E53;
                height: 100px;
                width: 90%;
                padding: 10px 10px 0;
            }

            .playable-canvas {
                border: 1px solid #4D4E53;
                border-radius: 2px;
            }

            .playable-buttons {
                text-align: right;
                width: 90%;
                padding: 5px 10px 5px 26px;
            }
        </style>

        <style type="text/css">
            .event-log {
                width: 25rem;
                height: 4rem;
                border: 1px solid black;
                margin: .5rem;
                padding: .2rem;
            }

            input {
                width: 11rem;
                margin: .5rem;
            }

        </style>

        <title>XMLHttpRequest: progress event - Live_example - code sample</title>
    </head>

    <body>
        <div class="controls">
            <input class="xhr success" type="button" name="xhr" value="Click to start XHR (success)" />
            <input class="xhr error" type="button" name="xhr" value="Click to start XHR (error)" />
            <input class="xhr abort" type="button" name="xhr" value="Click to start XHR (abort)" />
        </div>

        <textarea readonly class="event-log"></textarea>

        <script>
            const xhrButtonSuccess = document.querySelector('.xhr.success');
            const xhrButtonError = document.querySelector('.xhr.error');
            const xhrButtonAbort = document.querySelector('.xhr.abort');
            const log = document.querySelector('.event-log');

            function handleEvent(e) {
                if (e.type == 'progress')
                {
                    log.textContent = log.textContent + `${e.type}: ${e.loaded} bytes transferred Received ${event.loaded} of ${event.total}\n`;
                }
                else if (e.type == 'loadstart')
                {
                    log.textContent = log.textContent + `${e.type}: started\n`;
                }
                else if  (e.type == 'error')
                {
                    log.textContent = log.textContent + `${e.type}: error\n`;
                }
                else if (e.type == 'loadend')
                {
                    log.textContent = log.textContent + `${e.type}: completed\n`;
                }
            }

            function addListeners(xhr) {
                xhr.addEventListener('loadstart', handleEvent);
                xhr.addEventListener('load', handleEvent);
                xhr.addEventListener('loadend', handleEvent);
                xhr.addEventListener('progress', handleEvent);
                xhr.addEventListener('error', handleEvent);
                xhr.addEventListener('abort', handleEvent);
            }

            function runXHR(url) {
                log.textContent = '';

                const xhr = new XMLHttpRequest();

                var request = new XMLHttpRequest();
                addListeners(request);
                request.open('GET', url, true);
                request.responseType = 'blob';
                request.onload = function (e) {
                    var data = request.response;
                    var blobUrl = window.URL.createObjectURL(data);
                    var downloadLink = document.createElement('a');
                    downloadLink.href = blobUrl;
                    downloadLink.download = 'download.zip';
                    downloadLink.click();
                };
                request.send();
                return request
            }

            xhrButtonSuccess.addEventListener('click', () => {
                runXHR('https://abbbbbc.com/download.zip');
            });

            xhrButtonError.addEventListener('click', () => {
                runXHR('http://i-dont-exist');
            });

            xhrButtonAbort.addEventListener('click', () => {
                runXHR('https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json').abort();
            });
        </script>

    </body>
</html>

Return to post

Reference: XMLHttpRequest: progress event, Live example

Related