javascriptmemory-managementmemory-leakswebkithtml5-filesystem

FileReader Page Memory Leak


We are building a React SPA. The app allows you to submit a form with photos many times in a row. We started stress testing the app and realized that it stops working on iOS (iPad Mini) after several dozens of file uploads. Profiled the memory in Safari and found that what WebKit calls Page Memory keeps climbing whenever we use FileReader.readAsArrayBuffer, but it never seems to be released. At some point, the App becomes very slow (it never crashes, but connections to local IndexDB start to drop).

Here is the barebones application created with create-react-app:

import FileReaderHandler from './FileReaderHandler';
import { useRef } from 'react';

function App() {
    const fileReaderHandler = useRef(new FileReaderHandler());

    const onSelectFiles = async (event) => {
        event.stopPropagation();
        event.preventDefault();
        const files = Array.from(event.target.files);
        for (const file of files) {
            await fileReaderHandler.current.readFile(file);
        }
    }

    return (
        <div>
            <input multiple type="file" accept="image/*"
                onChange={event => onSelectFiles(event)}
                onClick={event => event.target.value = null}
            />
        </div>
    );
}

export default App;

App UI

Here is the FileReaderHandler implementation:

export default class FileReaderHandler {
    async readFile(file, fileReader) {
        return new Promise((resolve, reject) => {
            const fileReader = new FileReader();
            
            function resolveHandler () {
                cleanHandlers()
                resolve(fileReader.result)
            }

            function cleanHandlers () {
                fileReader.removeEventListener("loadend", resolveHandler);
            }

            fileReader.addEventListener("loadend", resolveHandler);

            fileReader.readAsArrayBuffer(file);
        })
    }
}

Here is the Safari timeline of me continuously selecting the same 10 images: Safari memory timeline

It makes no difference if the code handling the reading is in the component itself or in the class. I also tried using a singleton FileReader passed into the function from component, without much luck. Read online that one possible reason memory leaks can occur is due to not cleaning up event handlers, but as you can see in the code above - I do remove the listener.

I tried a version of this code without the Promise just in case the anonymous function somehow holds on to the references, but also no luck.

Finally, I also tried URL.createObjectURL/revokeObjectURL, and it has a similar effect on memory.

Refreshing the page is not an option, because the app needs to work offline and there are background processes running.

From what I understand from the WebKit link above, the memory is not on Heap, but rather some internal cache. Can someone with better understanding of WebKit memory management explain what is happening and how to prevent this?


Solution

  • Webkit Inspector was misleading/indicating a red herring problem in that it was showing page memory increase, but only while the Inspector tool was open and recording the timeline. I did another experiment where I used the file picker to open files many times in a row and only then started recording the timeline in the Inspector - it showed memory to not be elevated.

    The actual issue in our app turned out to be caused by code not cleaning up the FileReader event handlers. There were several places where a FileReader was used without cleanup, including an external image resizing library.

    There are two area for improvement that I see:

    1. Online docs (such as MDN) should be updated to reflect correct FileReader usage - seems like most npm packages out there are not cleaning up the event handlers, which can cause memory leaks over time in long-running applications.
    2. Webkit Inspector can be improved by correctly showing the page memory allocation, so that people don't chase after red herrings.