A bookcase full of books, the original database

How to use IndexedDB in JavaScript

IndexedDB is a database built into every browser. Learn when it's a better choice than local storage, and how to use it in JavaScript with async/await. You'll store objects and images, find them with an index and keep your data from being deleted.

In this tutorial you'll build a sketchbook that keeps your drawings, even after you close the browser. Along the way you'll find out how the database works, where it can go wrong and how much you can store in it.

What is IndexedDB?

IndexedDB is a database that's built into the browser. It stores JavaScript objects, including files and images, and finds them again by a key or an index. It's asynchronous: you ask for something, and the answer comes a moment later, so your page doesn't have to wait. Every browser supports it, and there's nothing to install.

Each website gets its own databases, and other websites can't read them. To be exact, each origin does: the protocol, domain and port together. So https://example.com and https://www.example.com each have their own. The data stays on the visitor's device until your code or the visitor removes it. Scribble, our to-do list and notes app, works like that: it keeps your tasks and images in IndexedDB, right in your browser.

IndexedDB has a few terms of its own. You'll see them throughout this tutorial:

  • Database - Has a name and a version number. You increase the version number when you change the stores in it. A website can have more than one.
  • Object store - The place where your objects go, a bit like a table. A sketchbook could have a store for drawings, and a game could have one for saved games.
  • Key - Every object in a store has its own key, like a number or a name. With the key, you can get the object back.
  • Index - A second way to find objects, by one of their properties. An index on the date gets you all drawings from the past week.
  • Transaction - Every read and write happens inside a transaction. If one step fails, none of the changes are saved.

When should you use IndexedDB?

There's more than one place to keep data. Here's what each of them is good for:

  • Local storage - A few small things, like settings or a high score. It's the easiest to use, but it only holds about 5 MB of text, and the whole page waits for every read and write.
  • Session storage - The same as local storage, but only for one tab, and only until that tab closes.
  • Cookies - Small pieces of data that the server needs to see, like a login session. The browser sends them along with every request.
  • IndexedDB - Large amounts of data, such as files and images, or lots of objects you want to search and sort. It takes more code, but it holds far more than local storage, and the page keeps running while IndexedDB reads and writes.
  • Cache API - Copies of your website's files, so your website still works offline. It's used by a service worker, a script that the browser runs in the background for your website. You don't need one for this tutorial.
  • Server-side storage - Anything that must never get lost, or that the visitor needs on other devices too.

Local storage is the main alternative to IndexedDB. Both keep your data in the browser, and local storage is easier to use. So when do you pick IndexedDB over local storage? When at least one of these is true:

  • More than a few MB - Local storage stops at about 5 MB per website. IndexedDB can use a big part of the disk.
  • Files and images - IndexedDB stores a photo or a drawing as it is. In local storage, it first has to become a long string of text, which takes about a third more space.
  • Lots of objects - With an index, you get exactly the objects you need, already sorted, without reading all of them.
  • Dates, maps and sets - IndexedDB keeps a Date, a Map or a Set as it is. Local storage only takes strings, so you'd have to convert everything to JSON and back.
  • An app that works offline - Together with a service worker, IndexedDB lets a web app work without a connection.

For a few settings, local storage is still the simpler choice. The tutorial on HTML5 local storage shows you how to use it.

Want to see the difference in size? The demo below asks your browser how much room it gives this website, and compares it with the room local storage gets:

Local storage gets about 5 MB per website in the major browsers, so its bar is barely visible. When it's full, setItem(), the method that saves a value in local storage, throws a QuotaExceededError. IndexedDB gets a share of your disk space, which on most devices is thousands of times as much. The demo gets the room for IndexedDB from navigator.storage.estimate() and doesn't store anything itself. You'll find out more about that function further down this page, in the section about how much data you can store.

Open a database

Everything in IndexedDB starts with indexedDB.open(). You give it the name of the database and a version number. If the database doesn't exist yet, the browser creates it:

let request = indexedDB.open('sketchbook', 1);

// Runs only for a new database or a higher version
request.onupgradeneeded = function () {
    let database = request.result;
    database.createObjectStore('drawings', {
        keyPath: 'id',
        autoIncrement: true
    });
};

request.onsuccess = function () {
    let database = request.result;
    console.log('The sketchbook is ready');
};

request.onerror = function () {
    console.log('Could not open the sketchbook', request.error);
};

open() doesn't give you the database right away. It returns a request, and when the browser is done, it fires an event on that request. That event is either success, with the database in request.result, or error, if something went wrong. You handle them with request.onsuccess and request.onerror, as in the code above. Almost everything in IndexedDB works like that: you ask, and the answer comes in an event.

The upgradeneeded event fires before success, but only when the database is new, or when you open it with a higher version than it has. It's the only place where you can create object stores. The code above creates a store called drawings.

The second argument of createObjectStore() says where the key of each object comes from. With keyPath: 'id', the key is the id property of the object. autoIncrement: true fills in that id for you: 1 for the first drawing, 2 for the second, and so on.

Wrap a request in a Promise

Events work, but they get messy as soon as one step depends on another. You end up with an event handler inside an event handler inside another one. A small function that turns a request into a Promise fixes that. A Promise is a placeholder for a result that isn't ready yet, like the database that's still opening. The tutorial on images and sprites uses Promises to load images, if you'd like another example.

Here's the function:

// Turn a request into a Promise, so you can use await
function waitForRequest(request) {
    return new Promise(function (resolve, reject) {
        request.onsuccess = function () {
            resolve(request.result);
        };
        request.onerror = function () {
            reject(request.error);
        };
    });
}

waitForRequest() calls resolve() with the result when the request succeeds, and reject() with the error when it fails. That means you can now use await. Inside a function marked async, await waits for a Promise and gives you its result, while the rest of the page keeps running.

Here's the code that opens the sketchbook, this time with the new function:

let database;

window.onload = init;

async function init() {
    database = await openDatabase();
    console.log('The sketchbook is ready');
}

function openDatabase() {
    let request = indexedDB.open('sketchbook', 1);

    // Runs only for a new database or a higher version
    request.onupgradeneeded = function () {
        request.result.createObjectStore('drawings', {
            keyPath: 'id',
            autoIncrement: true
        });
    };

    return waitForRequest(request);
}

openDatabase() returns the Promise, and init() waits for it. From now on, the variable database holds the open database. The examples below use await too, so run them inside an async function like init(), after the line that opens the database.

Add and read data

To store an object, you start a transaction on the store, and ask the store to add the object. transaction() takes the name of the store and a mode: 'readwrite' to change data, or 'readonly' to only read it. Here's how you add a drawing:

let transaction = database.transaction('drawings', 'readwrite');
let store = transaction.objectStore('drawings');

let drawing = {title: 'My first drawing', created: new Date()};
let id = await waitForRequest(store.add(drawing));

console.log('Saved with id ' + id);

add() gives you the key of the new object: the id that autoIncrement picked. The object in your code doesn't get that id, though. If you need it later, add it yourself, with drawing.id = id.

You'll write the first two lines a lot, so here's a small function for them:

// Start a transaction and get the drawings store
function getStore(mode) {
    let transaction = database.transaction('drawings', mode);
    return transaction.objectStore('drawings');
}

Reading works the same way, with a 'readonly' transaction. get() finds one object by its key, getAll() gives you all objects in the store, and count() tells you how many there are:

let store = getStore('readonly');

let drawing = await waitForRequest(store.get(1));
let allDrawings = await waitForRequest(store.getAll());
let count = await waitForRequest(store.count());

If there's no object with that key, get() gives you undefined, not an error. The examples on this page use 1 as the id. When you try them, use an id that's in your store.

There's one more way to store an object: put(). The difference is what happens when the key is already in use:

  • add() - Fails with a ConstraintError if the store already has an object with the same key, for example when the object already has an id. Use it to add new objects.
  • put() - Replaces the old object. If there isn't one, it adds a new one.

What can you store in IndexedDB?

Local storage only takes strings, so an object first has to become JSON. IndexedDB stores the object itself. The browser makes a copy of it with the structured clone algorithm, and that copy keeps a lot more than JSON does:

  • Simple values - Strings, numbers, booleans, null and undefined.
  • Objects and arrays - Including objects inside objects, nested as deep as you like.
  • Dates - A Date comes back as a Date, not as a string.
  • Maps and sets - They stay a Map and a Set.
  • Blobs and files - Images, sounds and other files, as they are. The sketchbook uses that for its drawings.
  • Typed arrays - Lists of plain numbers, like a Uint8Array with the tiles of a level.

There are two things IndexedDB can't fully copy. An object made from a class comes back as a plain object: the properties are still there, but the methods are gone. If your game has a Bee class and you store a bee, you get back its x and y, but not its fly() method, so create a new Bee from those values when you load it. And a function can't be copied at all. You can try this without a database: structuredClone() makes the same copy that IndexedDB makes.

// structuredClone() copies the way IndexedDB does
let copy = structuredClone({created: new Date(), tags: new Set(['sky'])});
console.log(copy.created instanceof Date); // true

// DataCloneError: a function can't be copied
structuredClone({onClick: function () {}});

add() and put() throw that DataCloneError right away, not through an error event, so wrap them in a try...catch if an object might contain a function.

Update and delete data

To change an object, read it, change it and put it back with put(). Do that in one 'readwrite' transaction, so nothing else can change the object in between:

let store = getStore('readwrite');

let drawing = await waitForRequest(store.get(1));
drawing.title = 'A sunset';
await waitForRequest(store.put(drawing));

delete() removes one object by its key:

let store = getStore('readwrite');
store.delete(1);

There are also two ways to remove everything at once. Be careful with them:

// Removes every object in the drawings store.
// There's no undo, so be careful.
store.clear();

// Removes the whole database, with all of its stores.
// There's no undo here either. Once it's gone, it's gone.
indexedDB.deleteDatabase('sketchbook');

All pages on a website share the same IndexedDB. That's handy, but it also means that one wrong line can delete another app's data. Call deleteDatabase() with the wrong name, or loop over all databases with indexedDB.databases(), and you can remove the databases of other apps on your website. Here on Spicy Yoghurt, for example, our to-do list app Scribble keeps everything you write in a database of its own, and the demos on this page never touch it. So give each app its own database, and only remove what your own code stored.

How transactions work

A transaction groups everything you read and write. It's all or nothing: if one request fails, the browser undoes every change in the transaction.

When a request succeeds, the browser has accepted the change, but it's only saved for good when the whole transaction is complete. To wait for that, here's a second small function:

// Wait until everything in the transaction is saved
function waitForTransaction(transaction) {
    return new Promise(function (resolve, reject) {
        transaction.oncomplete = function () {
            resolve();
        };
        transaction.onerror = function () {
            reject(transaction.error);
        };
        transaction.onabort = function () {
            reject(transaction.error);
        };
    });
}

It listens for three events: complete when everything is saved, error when a request fails, and abort when the transaction is canceled, for example because the disk is full. Every store knows its own transaction, so after getStore() you can write waitForTransaction(store.transaction).

So which one do you wait for? Wait for the request when you need its result, like the id that add() returns. Wait for the transaction when you need to know the change is saved, like before you tell the visitor it's done. If you need neither, you don't have to wait at all: the transaction still saves your changes when it's done.

All or nothing is what you want when two changes belong together, like moving a drawing to a trash store: deleting it from one store and adding it to the other. Pass an array with the names of both stores to transaction(), and get each store from the transaction:

let storeNames = ['drawings', 'trash'];
let transaction = database.transaction(storeNames, 'readwrite');
let drawingStore = transaction.objectStore('drawings');
let trashStore = transaction.objectStore('trash');

let drawing = await waitForRequest(drawingStore.get(1));

// Both happen, or neither does
trashStore.add(drawing);
drawingStore.delete(1);
await waitForTransaction(transaction);

The trash store needs its own createObjectStore() in upgradeneeded. For a database that already exists, that means opening it with a higher version number, as explained further down this page, in the section about upgrading your database.

There's one thing about transactions that trips up almost everyone. A transaction stays open only while it has work to do. When it has no request running, and your code doesn't give it a new one before waiting for something else, the browser closes it. That goes for a new transaction without any requests, too. Waiting for fetch() or a timer is enough to close it:

let store = getStore('readwrite');

// Wrong: the transaction closes while fetch() is still running
let response = await fetch('drawing.png');
let image = await response.blob();
store.add({created: new Date(), image: image});

The last line throws a TransactionInactiveError. Waiting for a request in the same transaction is fine, because then the transaction still has work to do. Waiting for anything else isn't. So get everything you need first, and start the transaction after that:

let response = await fetch('drawing.png');
let image = await response.blob();

let store = getStore('readwrite');
store.add({created: new Date(), image: image});
await waitForTransaction(store.transaction);

In general, keep transactions short. While a 'readwrite' transaction runs, other transactions that write to the same store have to wait for it.

Find data with an index

So far, you've found drawings by their id. But what if you want all drawings from the past week? Without an index, you'd have to read every drawing and check its date. An index does that work for you: it keeps the objects sorted by one property, so the browser can jump straight to the ones you need.

You create an index with createIndex() on the store, in upgradeneeded. The first argument is the name of the index, and the second one is the property it sorts by:

store.createIndex('created', 'created');

The sketchbook from the start of this tutorial already exists at version 1, so its upgradeneeded won't run again. That's why you'll add the index in version 2. Further down this page, the section about upgrading your database shows how. Until you add the index, asking for it with index('created') throws a NotFoundError. While you're trying things out in your own project, you can also delete the database in DevTools, as explained near the end of this page.

Now you can ask the index for a range of values with IDBKeyRange. This code gets all drawings from the last seven days:

// Date.now() is in milliseconds, so this is 7 days ago
let weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
let range = IDBKeyRange.lowerBound(weekAgo);

let index = getStore('readonly').index('created');
let recentDrawings = await waitForRequest(index.getAll(range));

Despite its name, IDBKeyRange also works on the values in an index, like the dates in created. IDBKeyRange has four ways to describe a range:

  • only(value) - Only objects with exactly this value.
  • lowerBound(value) - This value and everything above it.
  • upperBound(value) - This value and everything below it.
  • bound(lower, upper) - Everything from the lower value up to the upper value.

For dates, above means later and below means earlier.

An index works with strings, numbers, dates and arrays. It doesn't work with booleans: an object with true or false in the indexed property simply doesn't show up in the index. Use 1 and 0 instead.

An index can also make sure that no two objects have the same value. Give it {unique: true}, and add() or put() fails with a ConstraintError when the value is already taken. That's handy for a username or an email address. As with any index, you create it in upgradeneeded:

store.createIndex('username', 'username', {unique: true});

Loop over data with a cursor

getAll() gives you everything at once. With hundreds of drawings, that's a lot of images in memory, while a gallery may only need the newest four. A cursor walks through the objects one at a time, in the order of the index, and you decide when to stop:

// The newest drawings first, with a cursor on the created index
function getNewestDrawings(maxCount) {
    return new Promise(function (resolve, reject) {
        let drawings = [];
        let index = getStore('readonly').index('created');
        let request = index.openCursor(null, 'prev');

        request.onsuccess = function () {
            let cursor = request.result;

            if (cursor == null || drawings.length == maxCount) {
                resolve(drawings);
                return;
            }

            drawings.push(cursor.value);
            cursor.continue();
        };
        request.onerror = function () {
            reject(request.error);
        };
    });
}

The first argument of openCursor() is a range, and null means all objects. The second one is the direction: 'next' goes from old to new, and 'prev' from new to old.

The success event fires once for every object. The current object is in cursor.value, and cursor.continue() asks for the next one, which fires success again. After the last object, the cursor is null. To stop earlier, like after four drawings, don't call continue() again. The same event fires again and again, but a Promise can only resolve once. waitForRequest() would only give you the first object, so getNewestDrawings() makes its own Promise.

A cursor can also change the object it's on, with cursor.update() and cursor.delete(). Chrome 141 and Firefox 153 added a shortcut: calling getAllRecords({direction: 'prev', count: 4}) on the index gets you the records of the four newest drawings in one call. As of November 2026, Safari doesn't support it yet, so a cursor is the safe choice for now.

Store images and files

A Blob is a file in memory: a bunch of bytes with a type, like image/png. IndexedDB stores a Blob as it is. A Blob usually comes from one of these three places:

  • From a canvas - canvas.toBlob() turns what's on the canvas into a PNG.
  • From a file input - The files of an <input type="file"> are File objects, and a File is a Blob with a name.
  • From the network - response.blob() gives you the file that fetch() downloaded.

canvas.toBlob() uses a callback, so it gets wrapped in a Promise too:

function canvasToBlob(source) {
    return new Promise(function (resolve) {
        source.toBlob(resolve, 'image/png');
    });
}

source is the canvas you want to save, and toBlob() calls resolve() with the Blob when the image is ready. Store it in a property, like any other value. Make the image before you start the transaction: the transaction won't wait for toBlob() either.

let image = await canvasToBlob(canvas);

let store = getStore('readwrite');
store.add({created: new Date(), image: image});
await waitForTransaction(store.transaction);

A file that the visitor picks with a file input goes in the same way:

let input = document.querySelector('input[type=file]');

input.onchange = async function () {
    let store = getStore('readwrite');
    store.add({created: new Date(), image: input.files[0]});
    await waitForTransaction(store.transaction);
};

To show a stored image in an <img> element, give it a URL with URL.createObjectURL(). On a canvas, createImageBitmap() turns the Blob into something that drawImage() can draw. In the code below, preview is the id of an <img> element on your page, and context is the context of your canvas:

let drawing = await waitForRequest(getStore('readonly').get(1));

// In an <img> element
let url = URL.createObjectURL(drawing.image);
document.getElementById('preview').src = url;

// On a canvas
let bitmap = await createImageBitmap(drawing.image);
context.drawImage(bitmap, 0, 0);

An object URL keeps the Blob in memory until the page closes. When you no longer need it, free the memory with URL.revokeObjectURL(url).

You could also turn an image into a data URL, a long string of text, and store that. But the string is about a third bigger than the Blob, and IndexedDB can store the Blob as it is.

Build a sketchbook that remembers your drawings

Time to put it all together. Draw something in the sketchbook below with your mouse or finger, and click Save drawing. Your drawing shows up in the gallery underneath. Reload the page, and it's still there:

Live demo The code updates as you play around
Color
// The drawings store in IndexedDB, newest first
// No drawings yet

The gallery shows your four newest drawings. Click one to open it. Then you can change it and save it again, or delete it with Delete drawing. Click New drawing to start over with a blank page. The sketchbook holds up to five drawings. That limit comes from maxDrawings in the demo's code, not from IndexedDB. It's there so the demo doesn't fill up your device's storage. Once the sketchbook is full, delete a drawing to make room for a new one. Older drawings stay in the database, and move up into the gallery when you delete a newer one. The code block under the buttons shows what's in the drawings store right now, newest first.

The sketchbook uses the functions from this tutorial: openDatabase(), waitForRequest(), waitForTransaction(), getStore(), canvasToBlob() and getNewestDrawings(). The drawing itself is on a second canvas called paper, which isn't on the page. That way, you can save the drawing without the gallery under it. The drawing code uses pointer events, which work for both mouse and touch. You can read more about those in the tutorial on keyboard, mouse and touch controls. Each time the pointer moves, a short line is drawn from the previous point to the current one, with lineTo(). Our tutorial about drawing on the HTML5 canvas shows how lines like that work.

Here's what happens when you click Save drawing:

async function saveDrawing() {
    // Only add a new drawing if there's room
    let isFull = (await countDrawings()) >= maxDrawings;
    if (currentDrawing == null && isFull) {
        alert('The sketchbook is full. Delete a drawing first.');
        return;
    }

    // Make the image before the transaction starts
    let image = await canvasToBlob(paper);

    try {
        let store = getStore('readwrite');

        if (currentDrawing == null) {
            // A new drawing: add() gives it the next id
            currentDrawing = {created: new Date(), image: image};
            let request = store.add(currentDrawing);
            currentDrawing.id = await waitForRequest(request);
        } else {
            // A drawing you opened: put() replaces it
            currentDrawing.image = image;
            store.put(currentDrawing);
        }

        await waitForTransaction(store.transaction);
    } catch (error) {
        // For example a QuotaExceededError when the disk is full
        console.log('Could not save the drawing', error);
    }

    await updateGallery();
}

currentDrawing is the drawing you're working on. For a new drawing it's null. In that case, saveDrawing() first checks whether there's room: countDrawings() calls count() on the store, and the sketchbook holds at most five drawings. If there's room, saveDrawing() calls add() and holds on to the id it returns. If you opened a drawing from the gallery, currentDrawing already has an id, and put() replaces the old version. If the disk is full, waitForTransaction() rejects with a QuotaExceededError, and the catch block handles it.

After saving, updateGallery() draws the gallery again. A small version of a drawing is a thumbnail, and in the code it's called a thumb:

async function updateGallery() {
    let drawings = await getNewestDrawings(thumbCount);
    let newThumbs = [];

    for (let drawing of drawings) {
        // Skip anything that isn't a drawing with an image
        if (!(drawing.image instanceof Blob)) {
            continue;
        }

        let bitmap = await createImageBitmap(drawing.image);
        newThumbs.push({drawing: drawing, bitmap: bitmap});
    }

    thumbs = newThumbs;
    draw();
}

thumbCount is 4, so getNewestDrawings() returns the four newest drawings, and createImageBitmap() turns each image into a bitmap that draw() can draw on the canvas. The check at the start of the loop skips objects without an image. If you tried the earlier examples on this page in your own sketchbook, your store also has drawings like {title: 'My first drawing'}. Without the check, createImageBitmap() would throw an error on those. Click a thumbnail, and openDrawing() draws its image on the paper and sets currentDrawing to that drawing. The complete code below has all of it, apart from a few extras in the demo on this page, like the code block that shows what's in the store.

Upgrade your database to a new version

Sooner or later, you'll want to change your database, like adding a new store or a new index. You can only do that in upgradeneeded, and that only runs when the version goes up. So you open the database with a higher version, and the browser fires upgradeneeded once.

The tricky part is that not every visitor has the same version. Someone who visits for the first time has no database at all, and someone who visited last month still has version 1. event.oldVersion tells you which version the visitor has, so you can run every step they missed. Here's openDatabase() for version 2:

function openDatabase() {
    let request = indexedDB.open('sketchbook', 2);

    request.onupgradeneeded = function (event) {
        // Version 1: the drawings store
        if (event.oldVersion < 1) {
            request.result.createObjectStore('drawings', {
                keyPath: 'id',
                autoIncrement: true
            });
        }

        // Version 2: an index on created
        if (event.oldVersion < 2) {
            let upgrade = request.transaction;
            let store = upgrade.objectStore('drawings');
            store.createIndex('created', 'created');
        }
    };

    return waitForRequest(request);
}

For a new visitor, oldVersion is 0, so both steps run. For a visitor who already has version 1, like you if you opened the sketchbook at the start of this tutorial, only the second one runs. request.transaction is the upgrade transaction. You need it when you change a store that already exists. The complete code below uses this version of openDatabase().

Never change an old step afterward: visitors who already have that version never run it again. Add a new step with the next version instead. You can't go back, though: opening a database with a lower version than it has gives a VersionError.

There's one catch. If the visitor has your website open in another tab, that tab still has version 1 open, and the upgrade has to wait for it. The tab that wants to upgrade then gets a blocked event, and its open() waits until the other tab closes the database. To prevent that, let every tab close the database when another tab wants to upgrade it:

// Another tab wants to upgrade the database, so let it
database.onversionchange = function () {
    database.close();
};

Add this right after the database opens, as init() does in the complete code. Do it from the start, even in version 1, because a tab that's still open keeps running the old code of your page. The old tab can't use the database after that, so show a message that asks the visitor to reload that tab.

How much data can you store?

A lot more than in local storage. Each browser has its own limit per website, and it depends on the size of the disk. These are the limits as of November 2026:

  • Chrome and Edge - Up to 60% of the disk.
  • Firefox - Up to 10% of the disk, with a maximum of 10 GB.
  • Safari - About 60% of the disk, since Safari 17.

IndexedDB shares that room with the rest of your website's storage, like the Cache API. On a phone with a small or almost full disk, there's a lot less. With navigator.storage.estimate(), you can ask how much room there is:

let estimate = await navigator.storage.estimate();
let usedMB = estimate.usage / 1024 / 1024;
let freeMB = (estimate.quota - estimate.usage) / 1024 / 1024;

console.log('Used: ' + usedMB.toFixed(1) + ' MB');
console.log('Free: ' + freeMB.toFixed(0) + ' MB');

usage and quota are in bytes, so dividing by 1024 twice gives you megabytes. The numbers are estimates, not exact values. The demo at the start of this tutorial uses them to show how much room there is. When you run out of room, the transaction fails with a QuotaExceededError. That's why saveDrawing() has a try...catch.

Keep the browser from deleting your data

Data in IndexedDB stays until your code removes it. There are a few exceptions, though:

  • The visitor clears it - Clearing a website's cookies and site data also removes its IndexedDB data.
  • Private windows - What your website stores in a private window is deleted once the visitor closes all private windows. Data from normal windows isn't touched, because private windows have their own separate storage.
  • Safari after 7 days - If someone uses Safari for 7 days without interacting with your website, Safari deletes everything your scripts stored, IndexedDB included. A web app that the visitor added to the home screen is different: Safari only counts the days the visitor uses that app, so its data is kept.
  • A full disk - When the device runs out of space, the browser can remove the data of websites that haven't been used for a while.

That last one only happens to best-effort storage, which is what every website gets at first. You can ask the browser to make it persistent, so it isn't removed when space runs out:

if (navigator.storage && navigator.storage.persist) {
    let isPersisted = await navigator.storage.persisted();

    if (!isPersisted) {
        isPersisted = await navigator.storage.persist();
    }

    console.log(isPersisted ? 'Persistent' : 'Best-effort');
}

Firefox asks the visitor for permission. Chrome and Safari decide on their own, based on how much the visitor uses your website, so persist() may return false. Ask when the visitor does something that shows the data matters to them, like saving their first drawing, and not as soon as the page loads. Don't count on persist() to protect your data from Safari's 7-day rule, though. What does help is a visit: every time the visitor interacts with your website, Safari starts counting from zero again.

Persistent storage still doesn't help when the visitor clears their data. For anything important, give the visitor a way to make a backup. That's why Scribble, our own to-do list and notes app, lets you save all your tasks to a file.

View your database in DevTools

Open the developer tools with F12, or Cmd+Option+I on a Mac. In Safari, first turn on Show features for web developers in the settings, under Advanced. Here's where to find IndexedDB:

  • Chrome and Edge - The Application tab, under Storage, IndexedDB.
  • Firefox - The Storage tab, under Indexed DB.
  • Safari - The Storage tab, under Indexed Databases.

Open the developer tools on this page, and you'll find the sketchbook database from the demo, with your drawings in it. In your own project, you can also delete an object or a whole database there while you're testing.

Is IndexedDB secure?

Only pages from the same origin can open a database: the same protocol, domain and port. Other websites can't get to it. But on your own website, every script can: your own code, but also an analytics script, an ad or a library. If someone manages to run their own script on your page, they can read everything too.

Also, the data isn't encrypted on disk, so anyone with access to the computer can read it. Don't store passwords, tokens or anything secret in IndexedDB. And remember that the visitor can change the data in DevTools: a high score or a saved game from IndexedDB is never proof of anything.

The complete sketchbook code

Here's the complete code for the sketchbook, all in one HTML file. Paste it into a file, open it in your browser and start drawing. In Chrome, Firefox and Safari, that works straight from your computer. If another browser doesn't keep your drawings after a reload, run the file on a local web server instead.

Common questions about IndexedDB

Is IndexedDB SQL or NoSQL?

NoSQL. An object store is a bit like a table, but without columns, and there are no SQL queries. You store JavaScript objects under a key, and find them by that key or by an index.

Is IndexedDB asynchronous?

Yes. Every read and write returns a request, and the result comes later in an event. That's why your page keeps running while IndexedDB does its work.

Is IndexedDB shared between tabs?

Yes. All tabs and windows of the same origin use the same databases. Local storage has the storage event to tell the other tabs that something changed, but IndexedDB has nothing like it. You can use a BroadcastChannel for that instead. Let's say you want to tell the other tabs that have the sketchbook open about a new drawing. First, each tab opens the same channel with let channel = new BroadcastChannel('sketchbook'). After a tab saves a drawing, it sends a message on that channel with channel.postMessage('saved'). The other tabs receive that message in channel.onmessage and read the drawings from IndexedDB again, so they show the new drawing too.

Does IndexedDB work in incognito mode?

Yes, in all major browsers, but it's separate from your normal browsing data, and everything is deleted when you close the private window. Firefox has supported it since version 115, released in 2023.

Does IndexedDB work offline?

Yes. The data is on the visitor's device, so it doesn't need a connection. To make a whole web app work offline, you also need a service worker that keeps the app's files in the Cache API.

Can you use IndexedDB in a web worker?

Yes. IndexedDB works in web workers and service workers too, with the same code. That's handy for a big import: the work happens in the background, so the page itself stays smooth.

Where is IndexedDB stored?

On the visitor's device, in the folder where the browser keeps its data, separately for each origin. You can't choose the location, and you shouldn't touch the files. To see what's inside, use DevTools.

Does IndexedDB work when you open an HTML file from your computer?

It does in Chrome, Firefox and Safari, so you can open the complete code from this tutorial straight from your computer. But web standards don't say how browsers should handle files on your computer, so other browsers may behave differently. For a real project, run it on a local web server or put it online.

Conclusion

IndexedDB is the database in your browser. It takes more code than local storage, but two small helpers, waitForRequest() and waitForTransaction(), let you use async/await, which keeps your code readable. You can now store objects, dates and images, find them with an index or a cursor, upgrade your database, and keep the browser from deleting it too soon. Use it when local storage gets too small, and keep local storage for the few small things it's good at.

You can It's the complete sketchbook in one HTML file.

What did you draw? And what are you going to store in IndexedDB? Let us know in the comments below. Want to use IndexedDB in a game? Learn how to make an HTML5 game step by step.