A Weird Imagination

Keeping web app data local

The problem#

Users don't tend to have a lot of control over their data in web apps. Most often, the data is stored on a server the user does not control—or, if they do control it, we're talking about self-hosting which is much more involved then just navigating to a web app in a browser. Alternatively, the data may be stored locally, but using various browser-specific mechanisms which make it difficult for the user to share, backup, or otherwise reason about the data the web app manipulates.

While desktop apps can replicate these problems, usually they store data in files either explicitly chosen by the user or in well-known locations.

The solution#

Files are a flexible interface to let users do whatever they want with their data, so let's use them for web apps, too.

To save a file to the user's computer, modified from this example:

function saveFile(filename, data, mimeType) {
  const element = document.createElement("a");
  const url = URL.createObjectURL(new Blob([data],
                                  { type: mimeType }));
  element.setAttribute("href", url);
  element.setAttribute("download", filename);
  element.click();
  URL.revokeObjectURL(url);
}
// Save a JSON file:
saveFile("hello.json",
  JSON.stringify({"Hello": "World!"}, null, 2),
  "application/json");

(Consider using beforeunload if the user has unsaved changes to make sure they really do have their data in the file, and not just in the browser.)

To load a file from the user's computer:

function loadFile() {
  const element = document.createElement("input");
  element.type = "file";
  return new Promise((resolve, reject) => {
    element.click();
    element.addEventListener("change",
      () => resolve(element.files[0]));
    element.addEventListener("cancel",
      () => reject("User canceled."));
  });
}
// loadFile() must be called from a real user click.
myButton.addEventListener('click',
  async (e) => myLoadFunc(await loadFile()));

The details#

Privacy, not security#

While designing web apps to store data on the user's local filesystem in theory grants the user total control over the privacy of their data, it's important to keep in mind that those guarantees are dependent on the security of your web app. Browsers are complicated execution environments that make it easy to run code from and send data to external servers without it being obvious that is happening by merely looking at the code. Careful programming and limiting dependencies can increase your confidence that your web app does not leak any data, but it's never going to be as certain as a desktop app that simply doesn't reference any networking libraries.

The original project I was doing this for was designed for working with information about students (which has strong legal protections) on machines that are locked down so they can't run custom desktop apps, but multiple users needed to be able to work on the same data. That led to the design of a web app that very carefully keeps all the data on the local machine, so my server (and hopefully no one else's either) ever sees the legally protected data.

Saving files#

Data format#

The code example above can save any data that the Blob constructor will accept. The most likely reason to modify that is if your data is already a Blob or because you're always calling JSON.stringify() so you just want to put that into your helper function. You might also want to consider compressing your data; that will discourage the user from inspecting and modifying the data which may or may not be desirable, for example if you're writing a game and want to add a speedbump to cheating by modifying the save file.

URI scheme#

URL.createObjectURL() has the advanage of always creating a URL of approximately the same length, so you don't have to worry about running into length limits. If you want the URI to work outside the web page for some reason, you can also create a data: URI using either FileReader.readAsDataURL() or encodeURIComponent() as shown in the original version of the function I based mine on.

Don't forget to save#

A major problem with the APIs available is that every save has to be an explicit action the user takes. Modern applications usually autosave, but the best we can do is reminding the user to save and keeping an extra copy of the data in localStorage or OPFS so the data isn't lost if the user navigates away or closes the browser without saving.

You can use beforeunload event handler to notify the user if they try to close the page without saving their data to a file:

window.addEventListener('beforeunload', function (e) {
  if (myAppHasUnsavedData()) {
    // Cancel the event
    // If you prevent default behavior in Mozilla Firefox
    //  prompt will always be shown
    e.preventDefault();
    // Chrome requires returnValue to be set
    e.returnValue = '';
  }
});

Loading files#

MDN has an article on multiple ways to ask the user for a file; my example above is based on one of the examples in that article. Note that since we get an open file dialog by creating an <input type="file"> and programmatically clicking on it, browsers require that call come from a real user click on something, likely a "load" button or some element styled to look like one.

The loadFile() function above returns a File object. To actually get data out of it, you use a FileReader, but the details depend on whether the file is text or binary. For example, the following code will parse a JSON object from a File:

function readJson(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = (e) => resolve(
                             JSON.parse(e.target.result));
    reader.readAsText(file)
  });
}
// Example usage:
myButton.addEventListener('click',
  async (e) => alert((await readJson(await loadFile()))
                     .importantInfo));

Since there's multiple steps that involve callbacks instead of async/await, I followed the Promise documentation on converting between the two styles.

Why not directly use the filesystem?#

Unfortunately, only Google Chromium/Chrome support the File System Access API that allows granting a web app full access to read/write/create/delete files within a directory. While the security concerns are understandable, this means that for cross-browser support, we are limited to explicitly saving/loading files.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.