javascriptconcurrencyindexeddbweb-worker

Are IndexedDB writes actually parallel?


I needed to write 50k+ records to IndexedDB in multiple stores (tables). I noticed, that the more stores you write to, the slower the writes are. After some testing, it seems to me that the writes aren't actually executed in parallel.

MDN says:

Only specify a readwrite transaction mode when necessary. You can concurrently run multiple readonly transactions with overlapping scopes, but you can have only one readwrite transaction for an object store.

As does W3:

Multiple "readwrite" transactions can’t run at the same time if their scopes are overlapping since that would mean that they can modify each other’s data in the middle of the transaction.

But I am writing to different stores (scopes) so i would expect the writes operations to be parallel too?

This is what i tried. Writing 50k records to store1 takes about 3 seconds as does writing to store2. Writing to both at the same time takes about 6 seconds. So correct me if I'm wrong, but that doesn't seem to be parallel.

CodeSandbox demo

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <script>
        let db;

        const request = indexedDB.open('idb_test', 1);

        request.onupgradeneeded = () => {
            const db = request.result;
            db.createObjectStore('store1');
            db.createObjectStore('store2');
        };

        request.onsuccess = () => {
            db = request.result;
        };

        function createMany(storeName) {
            console.time(storeName);

            const tx = db.transaction(storeName, 'readwrite');
            console.log('tx durability:', tx.durability);

            for (let i = 0; i < 50000; i++) {
                const key = crypto.randomUUID();
                const val = i.toString();

                tx.objectStore(storeName).put(val, key);
            }

            tx.oncomplete = () => {
                console.timeEnd(storeName);
            };

            tx.commit();
        }

        function store1() {
            createMany('store1');
        }

        function store2() {
            createMany('store2');
        }

        function bothStores() {
            createMany('store1');
            createMany('store2');
        }
    </script>

    <body>
        <button onclick="store1();">store1</button>
        <button onclick="store2();">store2</button>
        <button onclick="bothStores();">store1 + store2</button>
    </body>
</html>



I also tried this with Web Worker just to see if it made any difference, but with same results.

CodeSandbox demo

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <script>
        // dev server is needed for this to work
        const worker1 = new Worker('worker.js');
        const worker2 = new Worker('worker.js');

        worker1.onmessage = (event) => {
            console.log('worker1 done');
        };
        worker2.onmessage = (event) => {
            console.log('worker2 done');
        };

        function store1() {
            worker1.postMessage('store1');
        }

        function store2() {
            worker1.postMessage('store2');
        }

        function bothStores() {
            worker1.postMessage('store1');
            worker2.postMessage('store2');
        }
    </script>

    <body>
        <button onclick="store1();">store1</button>
        <button onclick="store2();">store2</button>
        <button onclick="bothStores();">store1 + store2</button>
    </body>
</html>

worker.js:

let db;

const request = indexedDB.open('idb_test', 1);

request.onupgradeneeded = () => {
    const db = request.result;
    db.createObjectStore('store1');
    db.createObjectStore('store2');
};

request.onsuccess = () => {
    db = request.result;
};

function createMany(storeName) {
    console.time(storeName);

    const tx = db.transaction(storeName, 'readwrite');
    console.log('tx durability:', tx.durability);

    for (let i = 0; i < 50000; i++) {
        const key = crypto.randomUUID();
        const val = i.toString();

        tx.objectStore(storeName).put(val, key);
    }

    return tx;
}

self.onmessage = (event) => {
    const storeName = event.data;

    const tx = createMany(storeName);

    tx.oncomplete = () => {
        console.timeEnd(storeName);

        postMessage(storeName);
    };

    tx.commit();
};

So I tried profiling.

Chrome profiling

I think it's pretty clear what's happening. This is from Chrome 121. I'm pretty sure the grey blobs are the actual writes happening (it just says "Task").


Firefox was a bit different but still not parallel. Also almost 2x faster than Chrome.

Firefox profiling


So am I doing something wrong or are IndexedDB writes actually not parallel?


Edit: added tx.commit(), moved console.time closer to transaction, tried all possible transaction durabilities, didn't make much difference

Edit: Tried single transaction scoped to both stores (store1 and store2), that creates 50k puts in each store. Similar results.

CodeSandbox demo

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <script>
        let db;

        const request = indexedDB.open('idb_test', 1);

        request.onupgradeneeded = () => {
            const db = request.result;
            db.createObjectStore('store1');
            db.createObjectStore('store2');
        };

        request.onsuccess = () => {
            db = request.result;
        };

        function createMany() {
            console.time('timer');

            const tx = db.transaction(['store1', 'store2'], 'readwrite');
            console.log('tx durability:', tx.durability);

            for (let i = 0; i < 50000; i++) {
                const key = crypto.randomUUID();
                const val = i.toString();

                tx.objectStore('store1').put(val, key);
                tx.objectStore('store2').put(val, key);
            }

            tx.oncomplete = () => {
                console.timeEnd('timer');
            };

            tx.commit();
        }
    </script>

    <body>
        <button onclick="createMany();">store1 + store2</button>
    </body>
</html>

Edit: Tried with 100k puts, 2 different machines, Chrome and Firefox, Windows and Debian, didn't make much difference.


Edit: Tried writing to 2 separate databases, still no real concurrency.

CodeSandbox demo

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <script>
        let db1;
        let db2;

        const request1 = indexedDB.open('idb_test_1', 1);
        request1.onupgradeneeded = () => {
            const db = request1.result;
            db.createObjectStore('store1');
        };
        request1.onsuccess = () => {
            db1 = request1.result;
        };

        const request2 = indexedDB.open('idb_test_2', 1);
        request2.onupgradeneeded = () => {
            const db = request2.result;
            db.createObjectStore('store1');
        };
        request2.onsuccess = () => {
            db2 = request2.result;
        };

        function createMany(db) {
            console.time(db.name);

            const tx = db.transaction('store1', 'readwrite');

            for (let i = 0; i < 50000; i++) {
                const key = crypto.randomUUID();
                const val = i.toString();

                tx.objectStore('store1').put(val, key);
            }

            tx.oncomplete = () => {
                console.timeEnd(db.name);
            };

            tx.commit();
        }

        function fillDb1() {
            createMany(db1);
        }

        function fillDb2() {
            createMany(db2);
        }

        function bothDbs() {
            createMany(db1);
            createMany(db2);
        }
    </script>

    <body>
        <button onclick="fillDb1();">db1</button>
        <button onclick="fillDb2();">db2</button>
        <button onclick="bothDbs();">db1 + db2</button>
    </body>
</html>

Solution

  • I did some more digging and found this in the webkit bug tracker:

    I think saying that the transactions are interleaved is more accurate than saying they run in parallel since they still take turns hitting the database

    and this:

    IndexedDB: Allow multiple transactions to interleave request execution

    Implement spec logic for allowing read-only transactions, and read-write transactions with non-overlapping scopes, to run concurrently. Transactions all still run in the same thread with tasks triggered via timers, so tasks and the underlying database operations are interleaved rather than truly parallelized

    Now this is for Safari, but it also aligns with the first profiling picture from Chrome that I posted in the original question. You can see the transactions are running at the same time and taking turns writing to the DB.

    I will just assume that Chrome works similarly. Chrome is using LevelDB for IndexedDB, which says one of it's limitations is:

    Only a single process (possibly multi-threaded) can access a particular database at a time.

    Furthemore, I realized that I was reading the 2018 W3 spec of IndexedDB and the latest version, that Josh linked, also says this:

    implementations are not required to start non-overlapping read/write transactions in parallel, or may impose limits on the number of started transactions

    That pretty much says it all.