Configuring Valkey Persistence: RDB vs. AOF for Laravel Caching, Sessions, and Queues
A few months ago we helped a team debug a mystery. Their host had rebooted a VPS for maintenance at 3 a.m. The server came back cleanly, Nginx was up, PHP-FPM was up, Valkey was up. Everything looked healthy. Except 4,000 queued jobs, invoices, welcome emails, webhook deliveries, had simply ceased to exist. No errors, no failed_jobs rows, no trace. Valkey had been running with persistence disabled, so every job sitting in those queue lists lived only in RAM. The reboot didn't crash anything. It just quietly forgot.
That's the thing about the persistence decision: most Laravel teams never consciously make it. You install Valkey (or you used to install Redis), point CACHE_STORE, SESSION_DRIVER, and QUEUE_CONNECTION at it, and move on. The defaults you inherited, often just RDB snapshots, or nothing at all inside a container, silently become your durability policy for three workloads with completely different requirements. Losing cache is a shrug. Losing sessions is an annoyance. Losing queues is data loss, full stop.
The timing matters more in 2026 than it used to. Redis 7.2, the last BSD-licensed release line, reached end-of-life on February 28, 2026 (dev.to fork retrospective), and Valkey is now the default in-memory store on AWS ElastiCache. Valkey 9.1, released in May 2026, benchmarks at roughly 2.1 million requests per second with a further ~10% memory reduction over the previous release (DanubeData). Performance is a solved problem. Durability is the part you still have to configure yourself, and this post walks through exactly how.
Key Takeaways
- Persistence needs differ per workload: cache can lose everything, queues can lose nothing. - RDB snapshots are cheap but lose minutes of writes; AOF with
appendfsync everysecbounds loss to ~1 second. - Hybrid mode (aof-use-rdb-preamble yes) is the modern default for anything durable, and it's on by default in Valkey (Valkey docs). - Run separate Valkey instances for cache and queues, withallkeys-lrufor cache andnoevictionfor queues. - Redis 7.2 hit end-of-life February 2026, so this config now lives invalkey.conffor most of us (dev.to).
What's Actually at Stake When Valkey Loses Its Data?
The blast radius of losing Valkey's dataset depends entirely on what Laravel stores in it. A cold cache costs you seconds of elevated database load. A lost queue costs you real work that customers paid for. Before touching a single config directive, you need to sort your keys into "annoying to lose" versus "unacceptable to lose", because the right persistence mode follows directly from that.
Pure cache. If CACHE_STORE=redis and the instance vanishes, Laravel simply regenerates every cache entry on the next request. Your database eats a thundering herd for a few minutes, response times spike, and then everything settles. For most applications this is a non-event. Persistence for a pure cache is optional, and arguably counterproductive on small servers, because you're paying disk I/O to protect data that was designed to be disposable.
Sessions. With SESSION_DRIVER=redis, losing the dataset logs out every user simultaneously. Nobody's data is corrupted, but every cart is emptied, every multi-step form is reset, and your support inbox knows about it within minutes. This sits in the middle: not catastrophic, but embarrassing enough that some durability is worth a little disk I/O.
Queues. This is the horror story from the intro. Laravel queues on the Redis driver are lists and sorted sets: queues:default, plus the delayed and reserved sorted sets that hold retries and scheduled jobs. There is no upstream copy. If Valkey forgets them, the jobs are gone, and unlike an exception in a worker, nothing lands in failed_jobs, so debugging the disappearance is miserable because there's literally nothing left to debug. Queues demand the strongest persistence you can afford.
Horizon metadata. Horizon stores its dashboard metrics, job history, and supervisor state in the same store as your queues. Losing it resets your graphs and recent-jobs list, which is tolerable, but since it usually shares an instance with queue data, it inherits queue-grade persistence for free.
If you're still weighing whether Valkey is the right engine for these workloads at all, we've covered why Valkey took over from Redis for Laravel caching and queues separately. The short version: for persistence purposes, everything in this post behaves identically to Redis 7.2.
How Does RDB Snapshotting Work?
RDB is Valkey's point-in-time snapshot mechanism, unchanged in concept since early Redis: at configured thresholds, Valkey forks a child process that writes the entire dataset to a compact binary file, dump.rdb, while the parent keeps serving traffic (Valkey docs). The catch is in that sentence twice. "Point-in-time" means everything written after the snapshot is unprotected. "Forks" means a memory and latency cost you must plan for.
The snapshot schedule lives in valkey.conf as save rules, each one meaning "snapshot if at least N changes happened in M seconds":
# /etc/valkey/valkey.conf
# Snapshot after 900s if >=1 key changed,
# after 300s if >=10 changed, after 60s if >=10000 changed
save 900 1
save 300 10
save 60 10000
dbfilename dump.rdb
dir /var/lib/valkey
# Abort writes if the last snapshot failed (safe default)
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yesWith those defaults, your worst case is losing up to 15 minutes of writes. For a busy queue processing hundreds of jobs a minute, "up to 15 minutes" can mean thousands of jobs. That's the fundamental RDB trade: excellent operational characteristics, weak durability guarantees.
The Fork and Copy-on-Write Spike
Here's the part that bites small VPSes. When Valkey forks, the child shares memory pages with the parent via copy-on-write. Pages only get duplicated when the parent modifies them during the snapshot. Under light write load that's a few megabytes. Under heavy write load, a meaningful fraction of the dataset gets copied, and in the worst case memory usage approaches double the dataset size for the duration of the snapshot.
We've seen this take down a 2 GB droplet running a 1.2 GB Valkey dataset: BGSAVE fires during a traffic spike, copy-on-write balloons, the kernel OOM killer picks a victim, and the victim is Valkey itself. Two mitigations. First, set vm.overcommit_memory = 1 in sysctl so the fork itself never fails. Second, size the server so the dataset stays comfortably under half of RAM if you rely on RDB under write-heavy traffic, and watch latest_fork_usec (more on that below) because fork time also grows linearly with dataset size and stalls the main thread while it runs.
Strengths: Compact single-file backups that are trivial to copy off-server; fastest possible restart times since loading an RDB file is much quicker than replaying a log; minimal steady-state I/O; great fit for disaster-recovery snapshots shipped to object storage.
Best for: Pure cache instances where you'd merely like a warm cache after a planned restart; session stores where losing a few minutes is acceptable; the backup layer underneath AOF in hybrid setups.
Considerations: You will lose everything written since the last snapshot on a crash or hard reboot; fork-based copy-on-write can transiently spike memory toward 2x on write-heavy instances; fork pauses grow with dataset size; the save thresholds mean quiet periods can leave data unprotected for the full 15-minute window.
How Does AOF Persistence Work?
AOF (append-only file) takes the opposite approach: instead of periodic snapshots, Valkey logs every write command to a file as it happens, and on restart replays the log to reconstruct the dataset (Valkey docs). Durability now depends on one question: how often does the log actually reach the disk? That's the appendfsync directive, and it's the single most consequential line in a queue server's config.
# /etc/valkey/valkey.conf
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
# fsync policy: always | everysec | no
appendfsync everysec
# Rewrite (compact) the AOF when it doubles in size, min 64mb
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# Don't block writes if a rewrite is fsyncing (trade tiny risk for latency)
no-appendfsync-on-rewrite noThe three appendfsync policies map to three honesty levels about data loss:
always: fsync after every write. You can lose at most one command on a crash, but throughput drops hard because everyLPUSHwaits on the disk. On commodity VPS storage this can cut write throughput by an order of magnitude. Reserve it for genuinely irreplaceable data where Valkey is the system of record.everysec: fsync once per second in the background. You lose at most about one second of writes on a hard crash. This is the default and the right answer for almost every Laravel queue. One second of jobs is a bounded, explainable loss; fifteen minutes is not.no: let the kernel decide, typically flushing every 30 seconds. Barely better than RDB for durability. Skip it.
AOF Rewrites and Restart Times
An append-only log grows forever, so Valkey periodically compacts it. A rewrite forks a child (same copy-on-write caveats as RDB) that writes the minimal set of commands to rebuild the current dataset, while new writes buffer and get appended at the end. The auto-aof-rewrite-* directives above trigger this automatically when the file doubles. Between rewrites, budget real disk for growth: a write-heavy queue instance can add hundreds of megabytes of AOF per day, and a full disk stops Valkey from accepting writes entirely. That failure mode deserves its own runbook, which we wrote up in our guide to disk-space prevention, detection, and recovery.
The other cost is restarts. Replaying a large command log is much slower than loading an RDB snapshot, and on big datasets a pure-AOF restart can take minutes while your workers throw connection refused. Hybrid mode, next, exists precisely to fix this.
Strengths: Bounded, tunable data loss (down to ~1 second with everysec); the log is human-auditable with valkey-check-aof; a partially written tail from a crash can usually be truncated and recovered rather than lost wholesale.
Best for: Queue instances, Horizon metadata, and any dataset where Valkey is the only copy of the data; session stores on applications where mass logout is a real business problem.
Considerations: Larger files and steady write I/O; rewrite forks carry the same copy-on-write memory spike as BGSAVE; slower restarts in pure-AOF form; appendfsync always is usually overkill and can bottleneck queue throughput below what your workers need.
Why Is Hybrid Persistence the Modern Default?
Hybrid persistence gives you RDB's fast restarts and AOF's tight durability in one mode, and Valkey ships it enabled by default whenever AOF is on (Valkey docs). The mechanism is simple: during each AOF rewrite, the child writes the dataset in RDB binary format as a preamble, and only commands received after the rewrite get appended as a plain-text log tail.
# /etc/valkey/valkey.conf
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yesOn restart, Valkey bulk-loads the compact RDB preamble, then replays only the short tail of recent commands. Restart times land close to pure RDB while data loss stays bounded by your appendfsync policy. Since the preamble regenerates on every rewrite, file growth stays managed too.
We treat this as the default answer for any instance holding data you can't regenerate. The honest question is no longer "RDB or AOF?" but "does this instance need durability at all?" If yes, hybrid with everysec. If no, RDB alone or nothing. If you're migrating from Redis, note that aof-use-rdb-preamble has defaulted to yes since Redis 5, so many teams are already running hybrid without knowing it, which is one of the few pleasant surprises in this area.
Strengths: Near-RDB restart speed with AOF-grade durability; automatic compaction keeps disk usage predictable; it's the upstream default, so you're not fighting the tooling.
Best for: Queue and Horizon instances, which is to say, the instance whose loss would have made this article's intro about you.
Considerations: Still fork-based rewrites, so the copy-on-write memory math still applies; the AOF file's mixed binary/text format means you can't casually grep the preamble portion; requires AOF enabled, so pure-RDB-only setups need two directive changes, not one.
RDB vs. AOF vs. Hybrid vs. No Persistence: The Comparison
One table, four options, the trade-offs that actually matter on a production Laravel box:
RDB only | AOF only ( | Hybrid (RDB preamble + AOF) | No persistence | |
|---|---|---|---|---|
Max data loss (crash) | Up to full save interval (minutes) | ~1 second | ~1 second | Everything |
Restart speed | Fast | Slow (full log replay) | Fast (bulk preamble + short tail) | Instant (empty) |
Steady-state disk I/O | Bursty (snapshot only) | Continuous appends | Continuous appends | None |
Disk footprint | Small, compact | Large between rewrites | Moderate, managed | None |
Fork memory spike | Yes (BGSAVE) | Yes (rewrite) | Yes (rewrite) | No |
Backup story | Excellent (copy one file) | Workable | Good | N/A |
Fit for Laravel queues | Risky | Good | Recommended | Never |
Fit for pure cache | Fine (warm restarts) | Wasteful | Wasteful | Fine |
Read the first row twice. It's the row the 3 a.m. reboot cares about.
Which Persistence Mode Fits Each Laravel Workload?
The clean answer is per-workload, which means the clean architecture is per-instance. Here's the recommendation table we apply to our own infrastructure:
Laravel workload | Persistence |
|
| Notes |
|---|---|---|---|---|
Cache ( | None, or RDB only | n/a |
| Cold cache is a performance blip, not data loss |
Sessions ( | Hybrid |
|
| Evicted sessions = random logouts under memory pressure |
Queues ( | Hybrid |
|
| The one you cannot get wrong |
Horizon metadata | Hybrid (shares queue instance) |
|
| Inherits queue settings |
Broadcasting (Reverb pub/sub) | None | n/a | n/a | Pub/sub messages are never stored; persistence is irrelevant |
Separate Instances Beat SELECT n
Laravel's default config/database.php nudges you toward one Valkey server with logical databases: default on database 0, cache on database 1, selected via SELECT n. That works, but it forces one persistence policy and one eviction policy onto every workload, because both appendonly and maxmemory-policy are server-wide, not per-database. You can't snapshot database 1 casually while fsyncing database 0 religiously. In our experience, logical databases are how teams end up with cache-grade durability protecting their queues without realizing it.
Running two valkey-server processes on different ports (or a dedicated cache host) costs a few megabytes of overhead and buys you independent persistence, independent eviction, independent maxmemory, and independent restarts. Restarting a misbehaving cache instance no longer risks your queue backlog. We covered wiring multiple connections into Laravel's queue config in our queues deep dive on connections, workers, and retry strategies, and the pattern is a few extra lines in config/database.php pointing each connection at its own port.
The Eviction Policy Trap
Persistence protects you from restarts. Eviction policy protects you from memory pressure, and it can destroy queue data while the server stays up. With maxmemory set and allkeys-lru active, Valkey evicts the least-recently-used keys when memory fills. On a cache, that's the whole point. On a queue instance, "least recently used" can be the list holding your job backlog, and Valkey will delete it silently to make room for new writes. No crash, no restart, jobs gone, persistence intact and faithfully persisting the deletion.
# Cache instance
maxmemory 512mb
maxmemory-policy allkeys-lru
# Queue instance: refuse writes rather than evict jobs
maxmemory 1gb
maxmemory-policy noevictionWith noeviction, a full queue instance rejects new writes with an error your application can actually see and alert on. A visible failure beats silent data loss every time.
How Persistence Works on Deploynix Servers
When Deploynix provisions servers, on DigitalOcean, Vultr, Linode, Hetzner, AWS, or a custom box, Valkey shows up in two places, and the separate-instances advice above maps directly onto them. Every app server includes a local Valkey instance alongside Nginx, PHP 8.4, and Supervisor, the stack we described in setting up a production-ready Laravel stack. And the dedicated cache server type runs Valkey as its sole job, TLS-enabled out of the box, so cache traffic between your app servers and the cache host is encrypted in transit without extra certificate wrangling.
That two-tier layout gives you the split for free: keep the pure cache on the dedicated cache server with allkeys-lru and light or no persistence, and keep queues on the app server's local instance (or a second dedicated instance) with hybrid persistence and noeviction. Database servers (MySQL, MariaDB, PostgreSQL) stay on their own boxes, so a fork-related memory spike on the Valkey side never competes with InnoDB's buffer pool.
On a provisioned server, the config lives at /etc/valkey/valkey.conf and the service runs under systemd as valkey. For applying changes, we'd suggest this order of preference. First, runtime changes with CONFIG SET, which take effect instantly with zero downtime:
valkey-cli CONFIG SET appendonly yes
valkey-cli CONFIG SET appendfsync everysec
valkey-cli CONFIG SET maxmemory-policy noeviction
valkey-cli CONFIG SET save "900 1 300 10 60 10000"
# Persist the runtime state back into valkey.conf
valkey-cli CONFIG REWRITECONFIG REWRITE is the step people forget: without it, your carefully applied runtime settings evaporate on the next restart, which is a particularly ironic way to lose your persistence config. If you edit valkey.conf by hand instead, restart deliberately, and only after confirming a fresh snapshot or AOF sync exists, because restarting a non-persistent instance is exactly the reboot scenario from the intro, self-inflicted. Deploynix's server monitoring also raises memory and disk alerts, which is where fork spikes and AOF growth surface first in practice; a memory alert that coincides with rdb_bgsave_in_progress:1 tells you the copy-on-write math is getting tight. Queue workers themselves are managed from the UI, so once the underlying store is durable, restarting workers after a deploy doesn't touch the data layer at all.
How Do You Monitor and Test Persistence in Production?
A persistence config you've never tested is a hypothesis, not a safety net. Two habits close the gap: watch the right metrics continuously, and rehearse the restart before reality schedules one for you.
INFO persistence is the primary instrument:
valkey-cli INFO persistence# Persistence
loading:0
rdb_changes_since_last_save:1204
rdb_bgsave_in_progress:0
rdb_last_save_time:1754121600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok
latest_fork_usec:41250Four fields earn alerts. rdb_last_bgsave_status and aof_last_write_status must be ok; anything else means snapshots or fsyncs are failing, usually because the disk is full, and with stop-writes-on-bgsave-error yes your application is about to find out loudly. rdb_changes_since_last_save tells you exactly how many writes are currently unprotected by RDB, useful for judging whether your save thresholds match reality. And latest_fork_usec is your fork-cost gauge: it reports the last fork's duration in microseconds, and since the entire server stalls during the fork itself, a value creeping past ~500,000 (half a second) on a growing dataset is an early warning that snapshot pauses will soon be user-visible.
LASTSAVE gives you a quick freshness check, and pairing it with BGSAVE makes a tidy pre-maintenance ritual:
# Unix timestamp of the last successful RDB save
valkey-cli LASTSAVE
# Force a snapshot now, then confirm the timestamp advanced
valkey-cli BGSAVE
valkey-cli LASTSAVEThen there's the test almost nobody runs: the controlled durability drill. On a staging box, or during a quiet window with a maintenance page up, push a handful of marker jobs onto a queue, restart the service, and count them afterward:
valkey-cli LPUSH queues:durability-test job1 job2 job3
sudo systemctl restart valkey
valkey-cli LLEN queues:durability-test # expect: 3
valkey-cli DEL queues:durability-testIf the count comes back 0, your persistence config is theater, and you've learned it for the price of three fake jobs instead of four thousand real ones. While you're at it, verify disk headroom for AOF growth: ls -lh /var/lib/valkey/appendonlydir weekly, or let disk alerts do it for you. One more production note: if your Valkey instance is reachable over the network rather than localhost, TLS isn't optional, since queue payloads routinely contain user data; terminate it in Valkey itself (tls-port, tls-cert-file, tls-key-file) rather than bolting on a proxy.
What Changes If You're Migrating From Redis?
Nothing, and that's the point. Valkey is wire-compatible with Redis on both RESP2 and RESP3, and every directive in this article, save, appendonly, appendfsync, aof-use-rdb-preamble, maxmemory-policy, carries over character for character (dev.to fork retrospective). Your existing redis.conf persistence block can be pasted into valkey.conf unmodified, and Valkey will even load an existing dump.rdb or AOF directory produced by Redis 7.x, which makes the migration itself a stop-copy-start operation rather than a data export.
Laravel doesn't notice either: phpredis and Predis speak to Valkey exactly as they spoke to Redis, so config/database.php needs a host change at most. With Redis 7.2 past its February 2026 end-of-life and Valkey 9.1 posting ~2.1M requests/second with another ~10% memory trim (DanubeData), the migration question has mostly answered itself; the compatibility details, RESP protocol versions, data formats, module edge cases, are in our technical deep dive on Valkey vs. Redis compatibility if you want the full audit before switching.
FAQ
Can I just enable both RDB and AOF instead of hybrid mode?
Yes, and it's a legitimate setup: AOF drives recovery (Valkey prefers it on restart because it's more complete), while standalone RDB snapshots give you clean, compact files to ship off-server as backups. Hybrid mode with aof-use-rdb-preamble yes additionally embeds the RDB format inside the AOF for fast restarts, and the three directives coexist happily. Most durable production instances end up running all of it.
How much data do I actually lose with appendfsync everysec?
At most about one second of acknowledged writes, and only on a hard crash or power loss, not on a clean systemctl restart, which fsyncs on shutdown. For a queue pushing 50 jobs per second, that's a worst case of ~50 jobs, versus thousands under RDB's multi-minute windows. If even one second is unacceptable, use appendfsync always for that instance and accept the throughput cost, or reconsider whether that workload belongs in a database-backed queue instead.
Do I need persistence on a cache-only Valkey instance?
No, and skipping it is often the better call: you save the fork spikes, the disk I/O, and the restart-time snapshot loading. The one argument for RDB on a cache is warm restarts, since reloading a recent snapshot avoids the thundering-herd effect of a fully cold cache after planned maintenance. If your database shrugs off a cold cache, run the cache instance with no persistence and a clear conscience.
Why is noeviction safe for queues? Won't Valkey just fall over when memory fills?
It refuses writes with an explicit error, which your application sees as a failed dispatch, something you can catch, retry, and alert on. That's the failure mode you want, because the alternative under allkeys-lru is Valkey silently deleting your backlog to make room. Pair noeviction with a maxmemory set well below server RAM (leaving fork headroom) and a memory alert, and a filling queue instance becomes a paged engineer instead of a postmortem.
Does any of this apply to Laravel Reverb broadcasting?
Mostly no. Broadcasting rides on pub/sub, and pub/sub messages pass through Valkey without ever being stored, so no persistence mode retains them; a subscriber that's offline misses the message regardless. The only broadcast-adjacent data worth persisting is whatever your app stores about presence or state in regular keys, which follows the normal cache-or-durable decision like everything else.
Where to Start This Week
The whole decision compresses into three moves. Split cache from queues so each gets its own policy: separate instances, not SELECT n. Give the queue instance hybrid persistence (appendonly yes, appendfsync everysec, aof-use-rdb-preamble yes) with noeviction, and let the cache instance stay fast and disposable with allkeys-lru. Then prove it: run the marker-job restart drill and watch INFO persistence come back clean.
Your next step is a five-minute audit that costs nothing: run valkey-cli CONFIG GET appendonly and valkey-cli CONFIG GET maxmemory-policy against whatever instance backs your production queues, today. If the answers are no and allkeys-lru, you're one host reboot away from the story this post opened with, and now you know exactly which four lines of valkey.conf fix it.