DEV Community

John
John

Posted on

Why Vaultwarden Feels Slow: KDF, SQLite vs MySQL vs PostgreSQL, Storage, WebSockets and Your Reverse Proxy

For a single self-hoster with a few hundred vault items, the database is almost never the bottleneck. Unlock time is dominated by client-side key derivation, which runs on your laptop or phone and never touches the server at all, and sync time is dominated by the size of the full vault payload plus the round trip through TLS and your reverse proxy. SQLite handles a personal Vaultwarden instance without breaking a sweat; the cases where MySQL, MariaDB or PostgreSQL earn their keep are about concurrency, replication and backup tooling, not raw speed. Fix the KDF settings and the storage medium first, and only then argue about backends.

TL;DR by reader profile:

  • Solo developer with one vault on a Raspberry Pi, say 400 logins and no attachments: stay on SQLite and move the data directory off the SD card, because random write latency on flash cards is what you are actually feeling, not the query engine.
  • Self-hoster who bumped PBKDF2 to 2,000,000 iterations after reading a hardening thread, then blamed the server: lower the iteration count or switch to Argon2id with modest memory, because that delay is CPU work on the client and no server change will remove it.
  • Small team of 5 to 15 users sharing an organization, run on a NUC or small VPS: keep SQLite unless you see write contention in the logs, and put effort into WAL mode, a real SSD and correct WebSocket upgrade headers instead.
  • Homelabber who already runs PostgreSQL for other apps and wants one backup path: move Vaultwarden to PostgreSQL for operational reasons, accepting an extra container and a migration, not because queries will get faster.
  • Anyone whose vault is fine on the LAN and slow over the internet, for example on mobile data: the problem lives in DNS, TLS handshakes, proxy buffering and connection reuse, so measure the request path before touching the database.
  • Operator with a heavy vault, thousands of items plus attachments and icon fetching enabled: budget for payload size and outbound icon requests, because those grow the sync response and the I/O profile far faster than item count alone suggests.

The central tradeoff: every setting that makes your vault harder to crack offline makes it slower to open on your own devices, and every backend that makes operations easier adds a moving part that can itself become the slow thing.


Table of contents


Is your Vaultwarden server slow, or is your client just busy?

Start by splitting the problem in two. Everything that happens before a network request leaves your device is client work, and everything after it is server work. Vaultwarden, the Rust reimplementation of the Bitwarden server API, only ever sees the second half.

Unlock is client work: deriving your master key from your master password runs entirely in the browser extension, desktop app or mobile app, so a five second unlock on a laptop with the server powered off is still a five second unlock.

Sync is server work: the client calls GET /api/sync, the server reads your ciphers, folders and organization data, serialises them to JSON and sends them back, so this is where SQLite, storage and your reverse proxy appear.

A quick test separates them: run curl -o /dev/null -s -w '%{time_total}\n' https://vault.example.com/alive from the same network as the client, because that endpoint touches the server without touching your vault contents.

Where you host changes the second half only: a home server on a gigabit LAN, a NAS, a VPS in another country and a managed box all differ in round trip time and disk behaviour, never in key derivation cost. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user.

Docker adds its own floor: container startup, health checks and cold page cache mean the first request after a restart is slower than the next hundred, so never benchmark a container in its first 30 seconds.


What happens between typing your master password and seeing your vault

The sequence is fixed, and only two of its five steps involve your server.

Key derivation runs locally: the client feeds your master password and your email address as salt into the configured KDF, either PBKDF2-SHA256 or Argon2id, producing a 256 bit master key. Bitwarden clients default new accounts to 600,000 PBKDF2 iterations, while accounts created years ago may still sit at 100,000, and Argon2id defaults to 64 MiB of memory with 3 passes and 4 lanes.

Authentication is one small request: the client derives a master password hash from that key and posts it to /identity/connect/token. The body is a few hundred bytes, the response is a JWT access token plus a refresh token, and the server hashes the submitted value again before comparing it.

The symmetric key is unwrapped locally: your account encryption key arrives wrapped and is decrypted with the master key on the device. Vaultwarden never sees it, which is exactly why no server tuning can shorten this part.

Then the vault is fetched and decrypted: the sync call returns the ciphers, and the client decrypts each one to build the list you scroll through. On a phone this per item decryption is real CPU work, separate from the download.

Locking is not logging out: with a vault timeout action of Lock, reopening only repeats key derivation and local decryption, with zero network traffic. With Log out, you repeat the whole sequence including authentication and a full sync.

So a slow first open after a reboot and a slow open after a 15 minute timeout have different causes, and confusing the two sends you tuning the wrong layer.


What makes the sync response grow, and when does that start to hurt?

Vaultwarden has no incremental sync. Every GET /api/sync returns the whole vault: profile, folders, collections, organization keys, policies, Sends and every cipher you can see. Add one login and the client re-downloads all of them. That is fine at 200 items and noticeable at several thousand, because the cost is linear in item count and in the size of each item.

Growth driver How it scales When you feel it
Cipher count Linear: each login, card, note and identity is a separate encrypted object with its own key material Above roughly a few thousand items on mobile, where JSON parsing and decryption happen per object
URIs and custom fields per item Multiplies the per-item payload, since each URI and field is separately encrypted Vaults built by importing browser passwords, which often carry several URIs per login
Secure notes Grows with note body length, not item count, so 50 long notes can outweigh 500 logins When people paste config files or recovery codes into notes
Organization membership Adds collections, policies and every shared cipher to your personal sync response Joining a second or third organization on a shared instance
Attachment metadata Only filenames, sizes and keys ride in the sync; the bytes are fetched separately Rarely, unless USER_ATTACHMENT_LIMIT is set high and items accumulate many files

Two practical levers exist. Clients can call GET /api/sync?excludeDomains=true to skip global equivalent domains, and enabling gzip or brotli compression at the reverse proxy shrinks a highly repetitive JSON body substantially on the wire without touching the database at all.


Is SQLite good enough for a self-hosted Vaultwarden vault?

For one person, one family or a small team, yes. Vaultwarden defaults to SQLite for a reason: a password vault is a read heavy workload with a tiny working set, and the whole database lives in a single file at /data/db.sqlite3 inside the container.

The write volume is genuinely small: a vault write happens when you add or edit an item, log in on a device, rotate a token or create a Send. Nobody edits 40 passwords per minute, so the single writer limitation almost never bites.

Attachments never enter the database: file bytes are stored under /data/attachments, so a vault with 300 uploaded files keeps a database whose size tracks item metadata, not payloads.

Concurrency limits are about writers, not readers: SQLite allows many concurrent readers with one writer at a time, and in WAL mode readers do not block that writer. Five people syncing at once is reads, not a write storm.

Backups are a file copy, not a dump job: sqlite3 /data/db.sqlite3 ".backup '/data/backup.sqlite3'" produces a consistent snapshot while the server is running, and PRAGMA integrity_check; verifies it afterwards.

The failure mode is storage, not the engine: SQLite on a network share or an SD card behaves badly because locking and fsync semantics degrade, which is a filesystem problem wearing a database costume.

Where the file physically sits matters more than which engine reads it, whether that is a home server, a NAS, a VPS or a managed box such as Yundera. Move to a client server database when your operational needs change, not because item counts grew.


When does moving to MySQL or MariaDB actually help?

Not for speed. Swapping SQLite for MariaDB adds a TCP hop, an authentication handshake and a connection pool between Vaultwarden and its rows. For a single user vault that makes each query slightly slower, not faster. The reasons to do it are operational.

You already run a MySQL or MariaDB server: one backup path, one monitoring dashboard and one restore drill beats a second unrelated file to remember. Point Vaultwarden at it with DATABASE_URL=mysql://vaultwarden:password@db:3306/vaultwarden.

You need real replication or point in time recovery: binary logs and a replica give you rollback to a moment, which a nightly file copy cannot. Vaults are exactly the data where restoring to five minutes before a bad bulk edit matters.

Your storage layer cannot do file locking properly: if the data directory must live on NFS, SMB or a clustered filesystem, moving state into a database server sidesteps the locking behaviour that makes SQLite unsafe there.

You have dozens of concurrent writers: an organization with automated user provisioning, frequent invites and constant item edits produces sustained write concurrency that InnoDB row level locking handles more gracefully.

The costs are concrete. You need an image built with the mysql feature, a database created with CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, and a migration step that copies the SQLite contents across before first boot. You gain a second container to patch, plus its memory: InnoDB defaults its buffer pool to 128 MB, which is more RAM than SQLite ever asked for. Tune DATABASE_MAX_CONNS, which defaults to 10, rather than leaving the pool to fight your server's own connection limit.


Does PostgreSQL make Vaultwarden faster, or just harder to run?

Harder to run, and no faster for a personal vault. The queries Vaultwarden issues are simple primary key and foreign key lookups over a handful of tables. No planner is going to shine there. PostgreSQL earns its place when you want its durability and tooling, not its throughput.

Dimension SQLite PostgreSQL
Per query overhead In process function call against a local file TCP round trip, protocol parsing and a pooled connection
Memory floor Effectively the page cache Vaultwarden already uses shared_buffers defaults to 128 MB, plus one backend process per connection
Backup and restore Copy or snapshot one file pg_dump for logical dumps, WAL archiving for point in time recovery
Migration effort None, it is the default Run pgloader or an equivalent converter, then verify row counts before cutover
Typical failure mode Filesystem locking or a corrupted file on bad storage Connection exhaustion, version upgrade of the data directory, container start order

Use DATABASE_URL=postgresql://vaultwarden:password@db:5432/vaultwarden and pin the image tag, for example postgres:16-alpine, because a major version bump will not start against an old data directory without an explicit upgrade step. That last detail causes more Vaultwarden outages than any query ever will.

The honest rule: choose PostgreSQL if you already operate it, standardise on it, or need WAL based recovery. Otherwise you have added a second container that must start first, stay patched and survive reboots, on whatever you run this on, a home server, a NAS, a VPS or a managed option such as Yundera. Complexity you do not need is itself a reliability cost.


How much does your storage medium change Vaultwarden write latency?

More than the database engine does. Every committed vault write ends in an fsync(), and that call does not finish until the device says the data is durable. Throughput is irrelevant here. A drive that streams 500 MB/s can still take milliseconds to acknowledge a 4 KB flush, and that flush is what your client waits on when you save a password.

SD cards and USB sticks are the worst case: small random writes with a sync barrier hit the controller's slowest path, and the constant rewriting also consumes write cycles. This is the single most common cause of a Raspberry Pi vault that feels sluggish on save.

Spinning disks pay physics on every flush: a 7200 rpm drive averages 4.17 ms of rotational latency per revolution, before seek time. Ten flushes in a save path becomes tens of milliseconds you cannot tune away.

SATA and NVMe SSDs collapse the problem: sync acknowledgement drops by orders of magnitude, which is why moving /data to an SSD usually ends the investigation before anyone touches DATABASE_URL.

Network shares turn each flush into a round trip: NFS and SMB add latency per sync and bring their own locking semantics, so this hurts both correctness and speed.

Measure before you argue: run fio --name=sync --rw=randwrite --bs=4k --fsync=1 --size=64m --filename=/data/testfile against the exact path your container writes to, then compare devices. ioping -W /data gives a quicker approximation.

Do this test inside the container, not on the host. Bind mounts, encrypted layers and virtual disks all sit between the two, and only the container's view reflects what Vaultwarden actually experiences.


WAL mode, fsync and Docker volume layout: the settings that decide write cost

Vaultwarden turns on SQLite write ahead logging by default via ENABLE_DB_WAL=true. That is the right default and you should leave it alone unless you have a specific reason.

WAL converts one random write into an append: commits go to db.sqlite3-wal sequentially instead of rewriting pages in place, which suits every storage medium and lets readers continue while a write is in flight.

Three files now hold your vault: db.sqlite3, db.sqlite3-wal and db.sqlite3-shm live together. Copying only the first one while the server runs can lose every commit still sitting in the WAL, which is why a snapshot taken with the SQLite backup API is safer than cp.

Checkpoints are the periodic cost: SQLite checkpoints automatically at 1000 pages, so with the default 4096 byte page size the WAL grows to roughly 4 MB before its contents are folded back into the main file. That fold is the one moment you pay bulk write cost, and it happens in the background.

Disable WAL only for network filesystems: set ENABLE_DB_WAL=false when /data sits on NFS or SMB, because shared memory indexing does not work reliably there. Accept slower writes as the price of correctness.

Volume choice changes fsync behaviour: a bind mount from a Linux host to /data is close to native. On Docker Desktop for macOS or Windows the same bind mount crosses a virtual machine boundary through a file sharing layer, and sync heavy workloads slow noticeably, while a named volume stays on the VM's own filesystem.

Never leave /data in the container's writable layer. Overlay filesystems are copy on write, and a docker compose down destroys it.


Do WebSocket notifications reduce sync traffic or add load?

They reduce it. The notification channel exists so that a change made on your phone reaches your laptop without every client polling for it. One idle connection is cheaper than repeated full sync requests.

One connection replaces repeated polling: clients open a persistent connection to /notifications/hub and receive a small message telling them something changed. Without it, they only refresh on unlock, on focus and on their own timer, so an edit can sit unseen for a long while.

The port layout changed: older Vaultwarden deployments ran a separate WebSocket listener on port 3012, while current versions serve it on the main HTTP port 8080 with WEBSOCKET_ENABLED=true. Old compose files that still publish 3012 confuse people into thinking the feature is broken.

Your proxy must pass the upgrade through: the request needs Upgrade and Connection headers forwarded for that path, otherwise the handshake fails silently and clients degrade to polling with no visible error.

Idle timeouts create reconnect storms: nginx defaults proxy_read_timeout to 60 seconds, so a connection with no traffic gets closed and every client reconnects on a loop. Raise it for the notifications path, or you have swapped occasional syncs for constant handshakes.

Mobile apps are a separate mechanism: the Bitwarden mobile clients rely on push notifications rather than this channel, which requires PUSH_ENABLED plus an installation ID and key registered with the upstream relay. Enabling WebSockets alone will not make your phone update instantly.

The load is a held file descriptor and a keepalive per connected device. For a household with 10 devices that is noise. The real cost is misconfiguration, not connections.

Top comments (0)