Skip to content

Persistent Storage in C++ Web Apps: WASMFS + OPFS with Emscripten

How to replace IDBFS with the newer WASMFS + OPFS backend in an Emscripten C++ project, get rid of JS callbacks, and handle saves correctly across browser lifecycle events

emscriptenwebassemblycppwasmfsopfsgame-engineweb

How to use WASMFS to get persistent storage for a web app in C++

Ever wanted to create an app in C++ and serve it on the web, but you need to store some data of the user and you can’t find any guide on the web? Here I will show you how to use the OPFS backend of the new WASMFS storage layer for any app built with emscripten.

The problem

I am making a game engine fully written in C++ and I want to use it to create some small scope games for game jams and such, so a web export is pretty much mandatory to have players test and play those games on itch. So for my web builds I naturally use emscripten for this.

What is emscripten? Emscripten is a cross compiling tool that enables devs to port a C++ app to webassembly so that it can run in browsers. It works really well with my usecase as I use SDL as the backend for all of my render calls and input handling and it is backed into emscripten so it pretty much builds out of the box without doing too much handling on my part.

But one of the major issues I faced using emscripten was the handling of files and more specifically the persistent storage of my saves. There are multiple ways given by this tool to access files. First, for all the static files (such as resources or shaders), you can precompile them directly into the binary with the linker flag --preload-file. This directly compiles the files into the wasm and you can access them with any basic file accessor such as fopen or fstream. This works really well for all the files known at compile time and is quite easy to use for the tradeoff of having a bigger .wasm size.

set_target_properties(${TARGET} PROPERTIES
    LINK_FLAGS "--preload-file res \
                --preload-file shader \
                --preload-file scripts")

Those files get baked in and are read-only, which is fine for shaders, scripts or any asset that doesn’t change at runtime.

The main issue comes from files that are not present during compile time or need to evolve during runtime such as save files. For those we need to use a different kind of file handling.

Thats where WASMFS comes into play.

WASMFS

So if you skim through the emscripten docs, you will see that there are a lot of different ways to access, manage and store files in emscripten. For a quick runthrough you have access to:

  • IDBFS — the original persistent storage backend, backed by the browser’s IndexedDB. It works but requires you to synchronize explicitly using EM_ASM and JavaScript callbacks every time you want to flush data to disk. It’s now being deprecated in favor of WASMFS.
  • PROXYFS — lets you proxy filesystem calls to another Emscripten worker or shared memory. Useful for sharing a filesystem between workers but not really relevant if you just want to persist save data.
  • WASMFS — the new filesystem layer. It’s a fully C++ API, supports multiple backends (including OPFS), and handles threading out of the box. This is the one we want.

Here the two mains filesystem solutions that interest us are the IDBFS backend and the newer WASMFS + OPFS backend.

Why WASMFS over IDBFS

First, IDBFS is being deprecated for the newer WASMFS so if you want to future-proof your app and you don’t know which to choose, it is better to onboard with the more user friendly WASMFS. Next is a simpler and fully C++ API for WASMFS so the integration is faster and easier with existing C++ code, whereas before the IDBFS functions like mounting or flushing a directory required you to drop into EM_ASM and JavaScript callbacks to manage the filesystem. Finally you get better threading support as WASMFS supports it out of the box, something that was not supported by the previous implementation.

That said, be careful: WASMFS still doesn’t have support for all backends, so if you are targeting browsers that don’t support OPFS you will still need to work with the old API if you want to have access to persistent storage.

Let’s get into it

Now that we know why we want WASMFS, let’s go through the actual setup step by step.

CMake configuration

So first things first, before you can even think about mounting OPFS you need to have the right linker flags in your CMake. The three that matter here are -sWASMFS to enable the new filesystem layer, -s FORCE_FILESYSTEM=1 so the filesystem actually gets linked even if emscripten doesn’t detect any explicit FS calls in your code, and then -pthread with -sPTHREAD_POOL_SIZE for threading:

set_target_properties(${TARGET} PROPERTIES
    LINK_FLAGS "-sWASMFS \
                -s FORCE_FILESYSTEM=1 \
                -sPTHREAD_POOL_SIZE=4 \
                -pthread \
                -s ALLOW_MEMORY_GROWTH=1")

One thing to be careful about with the thread pool size: WASMFS internally defers its async operations to a background thread, so you always need to account for that extra thread on top of whatever your app already uses. If you don’t allocate enough threads your app can just stall waiting on IO with no obvious error.

Mounting the OPFS backend

Now for the actual mounting, first you need to include the wasmfs header, under an __EMSCRIPTEN__ guard of course:

#ifdef __EMSCRIPTEN__
#include <emscripten/wasmfs.h>
#endif

Then before you start your main loop you create the backend and mount it to a directory:

#ifdef __EMSCRIPTEN__
    std::string savePath = "/" + config.saveFolder;   // e.g. "/save"
    backend_t backend = wasmfs_create_opfs_backend();
    int err = wasmfs_create_directory(savePath.c_str(), 0777, backend);
    if (err != 0 && errno != EEXIST)
        printf("Warning: OPFS mount returned %d (errno=%d)\n", err, errno);
#endif

Two things to watch out for here. First the path needs to start with /, that’s just how emscripten’s virtual filesystem works, the root is always /. On desktop you use relative paths but on web everything has to be absolute. Second don’t worry about EEXIST in your error check, that just means the directory was already there from a previous session which is actually the happy path for save data.

Since the path format differs between builds you need a small conditional when you construct it:

std::string constructSavePath() const
{
#ifdef __EMSCRIPTEN__
    return "/" + saveFolder + "/" + saveSystemFile;
#else
    return saveFolder + "/" + saveSystemFile;
#endif
}

On desktop you get save/system.sz, on web /save/system.sz. Same logical location, just the web one maps into the OPFS-backed virtual directory you just mounted.

Reading and writing files

This is actually the nicest part of WASMFS compared to IDBFS: once the backend is mounted you don’t need any special API to read or write to it. Standard C++ file I/O just works:

// Write
std::ofstream out{"/save/system.sz"};
out << data;

// Read
std::ifstream in{"/save/system.sz"};
std::string content((std::istreambuf_iterator<char>(in)),
                     std::istreambuf_iterator<char>());

WASMFS transparently routes anything under /save/ to the OPFS backend and persists it across sessions. No manual flush, no sync call, no JS callbacks. That’s pretty much the whole point of switching to it.

Saving on browser lifecycle events

One thing that can catch you off guard: the browser doesn’t guarantee your writes make it through if the user just slams the tab closed. To cover that you need to hook into two browser lifecycle events.

The first is visibilitychange which fires when the tab becomes hidden, so things like switching tabs or minimizing the window. The second is beforeunload which fires synchronously right before the page unloads on refresh or close. Together they cover pretty much all the cases:

#ifdef __EMSCRIPTEN__
    // Tab hidden: user switched tabs, minimized, etc.
    emscripten_set_visibilitychange_callback(nullptr, false,
        [](int, const EmscriptenVisibilityChangeEvent* e, void*) -> EM_BOOL {
            if (e->hidden)
                saveNow();
            return EM_TRUE;
        });

    // Page close / refresh
    emscripten_set_beforeunload_callback(nullptr,
        [](int, const void*, void*) -> const char* {
            saveNow();
            return nullptr;  // nullptr = no "Are you sure?" dialog
        });
#endif

beforeunload fires synchronously so a blocking write is fine there. visibilitychange is the one that covers mobile browsers where beforeunload is unreliable, so you really want both.

Threading and initialization order

The last piece of the puzzle is the initialization order, and this one matters more than it looks. The full sequence needs to go like this:

exec()
  ├─ wasmfs_create_opfs_backend()     ← must happen before the main loop
  ├─ wasmfs_create_directory(...)
  ├─ SDL_Init(...)
  ├─ SDL_CreateWindow(...)
  ├─ std::thread initThread { initializeWindow() }   ← window/ECS init in background
  └─ emscripten_set_main_loop_arg(mainLoopCallback)  ← hands control to the browser

The OPFS mount has to happen before emscripten_set_main_loop_arg because WASMFS internally creates promises that need a running browser event loop to resolve. If you mount after handing control to the browser you can end up in a situation where the promises never resolve.

The window initialization goes into a background thread so the main loop can start right away and give those promises room to process. Then in the main loop callback you just check an atomic flag before doing anything:

static void mainLoopCallback(void* arg)
{
    Engine* engine = ...;

    if (not engine->windowReady.load())
        return;  // still waiting for initThread

    if (not engine->initialized)
    {
        engine->initializeECS();
        engine->initialized = true;
    }

    // normal render / event loop
}

This is what avoids the deadlock where all your pthread pool threads are sitting blocked on OPFS operations with nobody left to run the event loop and resolve the pending promises. If you ever find your app just hanging on startup with no error, this is probably why.

Wrapping up

That’s all there is to it. WASMFS with the OPFS backend is honestly a much cleaner solution than what came before — no JS callbacks, no manual syncing, just standard C++ file I/O that persists across sessions. The only real gotchas are the thread pool size and the initialization order, and now you know about both.

If this was useful to you or you ran into a different issue with the setup feel free to leave a comment, I’d be happy to extend the article with more edge cases.


If you are curious about the engine this was built for, PgEngine is a C++ game engine targeting both desktop and web. You can check it out and leave a star on the repo here: ColumbaEngine repository

Live demos at: columbaengine.org/demos

Discord: Join our community

If you played one of the games made with it and want to leave some feedback or just say hi, you can find them on my itch page here: Itch