Browser storage APIs for personal projects with Web Storage, IndexedDB, and OPFS

Web apps typically store data in a server-side database. At the same time, browsers themselves provide advanced mechanisms for storing data. Used well, these APIs can deliver a native-app-like user experience entirely in the browser: fast editors that work offline, image editing tools that handle large files, personal note apps that remain useful without a server, and more.

Recently, more people have also been building personal tools through “vibe coding,” where they ask AI to put something together quickly. For personal tools, keeping data entirely inside the browser can often be simpler and safer than setting up a server-side database.

This article introduces three common options for storing data in the browser: Web Storage (localStorage / sessionStorage), IndexedDB, and OPFS (Origin Private File System). It also summarizes where each one fits best.

Comparing the three options

First, let’s compare the characteristics of the three storage options.

Web Storage
(localStorage / sessionStorage)
IndexedDB OPFS
Data you can store Strings only Structured data
(objects, Blob, ArrayBuffer, and more)
Files (binary and text)
Typical capacity Up to about 5 to 10 MB Up to several GB
(depends on the browser and free disk space)
Up to several GB
(depends on the browser and free disk space)
API style Synchronous key-value API Asynchronous object stores Asynchronous file system API
How data is accessed By key By primary key or index By directory and file
Main use cases Small amounts of data such as settings and UI state Large amounts of structured data Large binary data and files
Learning cost Low Moderate. Consider using a library. Generally simple

Each storage option has different strengths. Web Storage is the easiest to use, but if you put everything into it without thinking, you are likely to run into capacity limits and performance issues.

Demo: a simple paint app that combines all three storage APIs

Let’s start with a simple demo that uses all three storage features.

This demo is a simple paint tool. You can add multiple layers and draw lines with any color and stroke width. Because the app uses browser storage, your settings and drawing do not disappear when you reload the page.

You should now have a rough sense of when each storage option is useful. From here, let’s look at the three options in more detail.

Option 1: localStorage / sessionStorage as a simple key-value store

The first option is the easiest one to use: Web Storage, which consists of localStorage and sessionStorage.

As the name suggests, Web Storage is a simple mechanism for storing string key-value pairs in the browser. localStorage persists even after the browser is closed, while sessionStorage is cleared when the tab is closed. Aside from that difference, their basic usage is the same.

Its API is minimal, and its biggest advantage is that you can start using it immediately. It is well suited for small pieces of frequently accessed data, such as settings and UI state: whether dark mode is enabled, partially entered form values, whether a sidebar is open, and so on. In the demo app, it was used to store brush color and stroke width.

How to use localStorage / sessionStorage

// Save
localStorage.setItem("theme", "dark");

// Read
const theme = localStorage.getItem("theme"); // "dark"

// Remove
localStorage.removeItem("theme");

// Remove everything
localStorage.clear();

To store an object, convert it to a string with JSON.stringify() first.

const settings = {
  color: "#FF0000",
  width: 4,
  selectedLayerId: "layer-1"
};
localStorage.setItem("settings", JSON.stringify(settings));

const loaded = JSON.parse(
  localStorage.getItem("settings") ?? "{}"
);

localStorage is easy to use, but it is not a place to put everything. Because it is a synchronous API, reading and writing large data blocks the main thread. Its capacity is also limited, typically to about 5 to 10 MB. It only handles strings, so binary data such as images is awkward to store, and converting such data to JSON increases its size.

For anything beyond storing “settings” or “a small amount of state,” the next option, IndexedDB, is usually a better fit.

Option 2: IndexedDB for large amounts of structured data

IndexedDB is an asynchronous object database built into the browser. Unlike localStorage, it can store many kinds of data as-is, including objects, arrays, Blob, and ArrayBuffer. It can also create indexes and perform range queries, so it can function as a database for applications that handle large amounts of data.

Its capacity is much larger than localStorage. Depending on the use case, it can store hundreds of MB or even several GB of data. It is a good fit when you want to store a collection of data with some structure, such as all items in a TODO app, cached bodies of RSS articles that have already been read, or operation history for undo/redo.

The demo app uses IndexedDB to save the user’s operation history. Each individual history entry is small, but saving thousands of records in localStorage would be difficult. Another important point is that transaction lets you update data safely.

Column: how IndexedDB differs from relational databases

When you hear “database,” you may think of relational databases (RDBs) such as MySQL or PostgreSQL. Some concepts, such as transactions and indexes, are shared. However, IndexedDB can feel confusing if you approach it like an RDB.

First, IndexedDB has no SQL-like query language. Data retrieval is basically done through lookups by primary key or index value, or through range scans. There is no straightforward way to write a filter such as age > 30 AND city = 'Tokyo'. You need to design compound indexes carefully, or retrieve data first and filter it in application code. There is also no equivalent of JOIN across multiple object stores, which are roughly comparable to tables in an RDB.

The schema model is also different. Object stores do not have column definitions. You can store objects in any shape, which is flexible, but the application must maintain data integrity. You cannot rely on RDB-style constraints such as NOT NULL or foreign keys. Migrations also need to be written manually inside the onupgradeneeded event.

If you need more complex queries, consider browser-based relational databases such as sqlite3 WebAssembly & JavaScript or PGlite. These can use IndexedDB or OPFS as their underlying storage.

How to use IndexedDB

IndexedDB is powerful, but its API is somewhat verbose. This section shows a minimal example for saving and retrieving data in a note app.

To use IndexedDB, open a database with indexedDB.open(). If the specified version is newer than the existing version, or if the database is being created for the first time, the onupgradeneeded event fires. Create object stores there. Object stores are roughly comparable to tables in an RDB.

// Open the database and initialize it at version 1
const openDB = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("myApp", 1);

    // Create the object store on first use or when upgrading the version
    request.onupgradeneeded = event => {
      const db = event.target.result;
      db.createObjectStore("notes", { keyPath: "id" });
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

The IndexedDB API is event-based and does not naturally fit with Promise, so this example wraps it in a Promise. Once this wrapper is written, the rest of the code can be written cleanly with async / await.

Next, let’s create functions for writing and reading notes by id. Both functions start a transaction with transaction(), then retrieve a store with objectStore() and operate on it.

// Write a note
const putNote = async note => {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readwrite");
    tx.objectStore("notes").put(note);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
};

// Read a note by id
const getNote = async id => {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readonly");
    const request = tx.objectStore("notes").get(id);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

// Example
await putNote({
  id: 1,
  title: "Shopping list",
  body: "Milk, eggs, bread"
});
const note = await getNote(1);
console.log(note); // { id: 1, title: "Shopping list", body: "Milk, eggs, bread" }

put() overwrites the data if a record with the same id already exists, and adds a new record if it does not. get() returns undefined if no record exists for the specified id.

Even this minimal example requires a fair amount of code. If you add searches, indexes, or operations across multiple stores, the amount of code grows further. In real projects, consider using a library that wraps IndexedDB. Common options include idb, a thin wrapper around the native API, and more full-featured database libraries such as Dexie.js and RxDB.

Option 3: OPFS as a full-featured, high-performance file system

The final option is OPFS (Origin Private File System), a private file system provided to web apps. If IndexedDB is a “database inside the browser,” then OPFS is “file storage inside the browser.”

This article does not cover it in detail, but another browser API for reading and writing files is the File System Access API. It lets a web app freely read and write files and directories on the device after receiving user permission, and Chrome strongly promoted it. Although powerful, it did not become widely adopted because of security concerns, and it also had performance issues. OPFS addresses those concerns by providing an app-specific directory in a location that users and other apps cannot directly see.

The biggest strength of OPFS is performance. It supports use cases that are too heavy for IndexedDB, such as streaming recorded video data to disk or working with large image data and binary files. It can also be used from Workers, making it suitable as a storage backend for apps that perform serious file processing.

The demo app uses OPFS to save image data for the layers. Because OPFS supports streams, the demo connects imageData from Canvas to CompressionStream and writes it to a file while compressing it. You may not notice the difference in a demo of this size, but when working with very large data, whether you can stream data has a major effect on performance.

How to use OPFS

Use navigator.storage.getDirectory() to get the root directory for OPFS. The basic flow is to get directory and file handles with getDirectoryHandle() and getFileHandle(), then read and write file contents with getFile() and createWritable().

Here is a minimal example, similar to the demo app, that compresses Canvas pixel data while saving it to OPFS. The key point is that the stream from CompressionStream can be piped directly into the WritableStream returned by createWritable().

// Get the root directory
const root = await navigator.storage.getDirectory();

// Get pixel data from Canvas as an RGBA byte array
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(
  0,
  0,
  canvas.width,
  canvas.height
);

// Get the WritableStream for the destination file
const fileHandle = await root.getFileHandle("layer.bin", {
  create: true
});
const writable = await fileHandle.createWritable();

// Connect byte array -> compression -> file write as a stream
await new Blob([imageData.data])
  .stream()
  .pipeThrough(new CompressionStream("deflate-raw"))
  .pipeTo(writable);

To read the file, pass the stream through DecompressionStream in the opposite direction, convert it back to a byte array, and draw it back to Canvas as ImageData.

// Read the file, decompress it, and restore it as ImageData
const file = await (
  await root.getFileHandle("layer.bin")
).getFile();
const buffer = await new Response(
  file
    .stream()
    .pipeThrough(new DecompressionStream("deflate-raw"))
).arrayBuffer();
ctx.putImageData(
  new ImageData(
    new Uint8ClampedArray(buffer),
    canvas.width,
    canvas.height
  ),
  0,
  0
);

Important considerations

Because all three storage options are provided by the browser, they come with considerations that differ from server-side databases and storage. Keep the following points in mind before adopting them.

Consideration 1: origin isolation means data becomes inaccessible when the domain or port changes

An origin is the combination of “protocol + host + port.” The following are all treated as separate origins.

  • https://example.com
  • https://sub.example.com … different subdomain
  • http://example.com … different protocol
  • https://example.com:8080 … different port

Data stored through these three storage APIs is completely separated by origin inside the browser. In other words, if the domain or port changes, you cannot access the data even in the same browser. This is desirable from a security perspective, but it can easily cause problems in production. Depending on the application, you should also consider ways to back up or migrate data, such as saving data on the server as well or providing export/import features for users.

Domain expiration also requires caution. Unlike server-side databases, browser storage cannot be forcibly deleted when a service shuts down. If an expired domain is acquired by a malicious third party, there is a risk that they could access data left in users’ browsers. Design operations carefully based on the nature of the stored data and the expected user base.

Consideration 2: on shared devices, other users may be able to see the data

Browser storage can be read and written by anyone who uses that browser. Its contents can also be viewed easily through developer tools, so passwords, API tokens, and other sensitive information must never be stored as plain text.

On a company shared PC or a family device, someone may leave the device while still logged in, or multiple people may use the same browser profile without switching profiles. This is no different from saving a file on the desktop, but whether users understand that is a separate issue.

Avoid storing sensitive information, and make it clear to users what data is being stored.

Consideration 3: stored data can be deleted, especially in Safari

Browser storage is a temporary area that may be deleted when there is not enough storage space. In particular, Safari may automatically delete storage for sites that have not been interacted with for seven days because of ITP (Intelligent Tracking Prevention). Interaction here means actions such as viewing the page and clicking or otherwise interacting with it.

For data that should not disappear, you can request persistent storage with navigator.storage.persist(), but this does not guarantee that the data will never be deleted. There are also many articles that recommend clearing browser storage as a way to “delete cache” when users are low on space, so users may unintentionally delete stored data.

Here too, important data should be stored on the server or made exportable so the application can tolerate deletion.

Browser support

Web Storage is available in all major browsers.

IndexedDB is also available in all major browsers. However, support for some features, such as the getAllRecords() method, differs by browser.

Reference: Can I use…: IndexedDB

OPFS is available in Chrome and Edge 108 (November 2022), Firefox 111 (March 2023), and Safari 16.4 (March 2023) or later.

Reference: Can I use…: Origin private file system

For all of these APIs, some options and behaviors in private browsing mode vary by browser environment. Before using them, check the specifications for the environments you support and verify the behavior there.

Conclusion

This article introduced three standard browser storage features.

  • Use localStorage / sessionStorage for small pieces of data such as settings and UI state.
  • Use IndexedDB for large amounts of structured data.
  • Use OPFS for large data such as binaries and files.

The key is not to treat these three options as mutually exclusive. As shown in the demo, they work well when used together with separate responsibilities. Combine them with server-side databases and storage where needed, and design your app around the strengths and limitations of browser storage.

Share on social media
Your shares help us keep the site running.
Post on X
Share
Copy URL
MATSUMOTO Yuki

Front-end engineer. Transitioned from SI and UX consulting into front-end engineering. Skilled at prototyping new ideas from the planning stage. Loves drawing and coding.

Articles by this staff