FiveM Data Persistence: Save Cadence, Dirty Flags and Not Losing Player Progress When the Server Crashes

Friday, 11:40pm, ninety-something people in the city. A guy has just spent forty minutes and most of his savings on a Sultan, driven it across the map, and stored it in his garage. Then the server thread hitches and FXServer goes down like a folding chair. Everyone reconnects six minutes later into a world that thinks it is twenty minutes ago. The Sultan is gone. So is the money. He is in your Discord within ninety seconds, and honestly, he has a point.

The gap between “I have a MySQL database” and “player progress actually survives a crash” is what fivem data persistence comes down to. Owning a database is table stakes. What decides whether a crash costs your players six minutes or their whole evening is when you write, what you write, and whether that write can safely be repeated. Below: where your state actually lives, why both obvious save strategies fail, dirty flags, staggered autosave, transactions, and what to do when a save is gone anyway.

Where your player data actually lives right now

Your player state is scattered across four places with very different survival odds, and most owners have never mapped which is which. That is why “but I have a database” feels like it should be enough.

Where it lives Survives a hard crash? What it is good for
The in-memory player table (QBCore.Players, ESX.Players) No The live truth for the current session
MySQL rows (players, player_vehicles, stashes) Yes, as of the last committed write Anything a player would open a ticket about
Resource KVP Yes on disk, but scoped to one resource and one machine Client-side preferences, small server flags
State bags No Replicating a current value, never storage

The model worth holding: the in-memory table is the truth, the database is a photograph of that truth taken some time ago, and your save cadence decides how old the photo is when the lights go out. Your worst-case rollback window is exactly the age of your last committed write.

One QBCore wrinkle: a lot of gameplay state has no column of its own. Hunger, thirst, stress, licences, jail time and gang membership all sit inside a single JSON metadata blob on the player row. Before writing your own save logic, read what QBCore player metadata actually holds, because the difference between a save that restores a character and one that restores a name is usually a forgotten key in that blob.

Why saving on every change and saving on disconnect both fail

Strategy one: write every time anything changes. Sounds bulletproof, and at thirty players it is. Then you count the changes. Every item pickup, every fuel purchase, every paycheck, and quietly worst of all, every hunger and thirst tick. If your metadata is one JSON column and hunger decrements on a timer, save-on-change rewrites that whole blob on a schedule, per player, forever. MySQL ends up fsyncing hundreds of writes a minute against your widest rows, and the garage query that used to take 8ms is queued behind them. Players call it lag, owners blame OneSync, and it is a write queue.

Strategy two: save only on disconnect. One write per session, beautifully efficient, and it works for every scenario except the one you are afraid of. playerDropped fires when the server notices somebody leave. When FXServer itself dies there is no server left to notice anything, and the same goes for the host OOM killer, a power cut, or a panicked kill -9.

The answer sits in the middle: write often enough that the loss window is small, cheaply enough that MySQL does not care. That second half depends on schema and query shape as much as Lua, and if your indexes are wrong then no save strategy will rescue you. This walkthrough of FiveM database performance pairs with everything below.

Dirty flags: only write the thing that changed

Most of a player row does not change most of the time. Position changes constantly, money a handful of times an hour, job maybe once a session, outfit roughly never. Writing all of it every time you write any of it is how a five-minute autosave becomes a five-minute stutter. So track dirtiness per domain: flip a flag in the setter, and let the autosave build an update from only the flags that are set.

local dirty = {}  -- [src] = { money = true, inventory = true, ... }

local function markDirty(src, field)
    local d = dirty[src]
    if not d then
        d = {}
        dirty[src] = d
    end
    d[field] = true
end

local function takeDirty(src)
    local d = dirty[src]
    dirty[src] = nil
    return d
end

Notice that takeDirty clears the flags before the write goes out. That ordering is the whole trick. If a player picks something up while the query is in flight, the field gets marked again and the next pass catches it. Clear after the write completes and you silently swallow every change that happened during it, which is a bug you will never reproduce on a test server with two people on it.

Two rules keep this honest. Flip the flag inside the setter, never at the call site, because anywhere a caller has to remember to mark something dirty is somewhere they will forget. And do not get clever about whether the value really changed, since deep-comparing an inventory table on every mutation costs more than the occasional redundant write. Position is the one exception worth carving out: three floats and a heading, cheap to write, worst loss-to-cost ratio on the row, so just write it on the interval regardless.

Staggered autosave so 200 players do not write in the same tick

Here is the loop almost every server ends up with:

CreateThread(function()
    while true do
        Wait(5 * 60000)
        for _, src in ipairs(GetPlayers()) do
            savePlayer(src)
        end
    end
end)

Every five minutes the entire population goes at the database at once. At 30 players nobody notices. At 200 it is the halftime toilet flush: the plumbing is perfectly adequate at every other moment of the day, then everybody stands up together. A crash makes it worse, because everyone reconnects inside the same sixty seconds and the stampede rebuilds itself.

Give each player their own deadline when they load in, randomised across the window, then sweep often and save whoever is due against a budget.

local nextSave = {}
local SAVE_INTERVAL = 300  -- seconds between saves for a given player
local SWEEP_BUDGET = 4     -- max saves per sweep tick

local function scheduleFirstSave(src)
    nextSave[src] = os.time() + math.random(1, SAVE_INTERVAL)
end

CreateThread(function()
    while true do
        Wait(2000)
        local now, budget = os.time(), SWEEP_BUDGET
        for src, due in pairs(nextSave) do
            if budget <= 0 then break end
            if now >= due then
                nextSave[src] = now + SAVE_INTERVAL
                savePlayer(src)
                budget = budget - 1
            end
        end
    end
end)

Call scheduleFirstSave from your framework’s player-loaded event (QBCore:Server:PlayerLoaded or esx:playerLoaded) and clear the entry on playerDropped. A two-second sweep with a budget of four flushes two players a second, which covers 200 players on a five-minute cycle while handing MySQL nothing but a trickle.

Check what your framework already does first. QBCore runs its own periodic save pass and ESX ships save functions with a loop behind them. Bolt yours on top without turning theirs down and you have simply doubled your write volume, which is an impressively literal way to make things worse.

The events that deserve an immediate save

Interval saves handle the boring drift. A few changes hurt too much to lose even for five minutes, and they share a shape: the player gave something up to get something else, and losing the wrong half of that trade feels like theft.

  • Money crossing a threshold the player will remember, like a vehicle or property purchase. If a receipt exists in their head, it should exist in your database.
  • A job or gang change, since it changes what every other resource lets them do, and being silently demoted mid-shift confuses people more than losing $500.
  • An inventory move between containers, whether player to stash, player to trunk, or player to player. That one gets its own section below.
  • Storing or retrieving a vehicle, since the garage state and the vehicle row have to agree or the car ends up in two places or none.
  • Anything backed by real money. If a webhook grants a donator pack, commit it immediately and log it, because “I paid and got nothing” is a chargeback waiting to happen.

An immediate save should still be a narrow save. Fire the targeted update, clear that one flag, and let the sweep pick up the rest. Buying a car should not rewrite somebody’s outfit.

What a shutdown handler can and cannot do when FXServer dies

The handler everyone writes, and should write:

AddEventHandler('onResourceStop', function(resource)
    if resource ~= GetCurrentResourceName() then return end
    for _, src in ipairs(GetPlayers()) do
        savePlayer(src)
    end
end)

That covers the polite exits: a stop command, a txAdmin restart, quit in the console, a scheduled maintenance window. The runtime is walking round the building switching lights off, and your handler gets a real moment to work.

What it does not cover deserves saying plainly. A segfault, an out-of-memory kill, a power cut or a kill -9 fires nothing at all, because no process remains to fire it. Async work started inside the handler is on borrowed time too, since the resource is already being torn down while your callbacks are pending. Queuing ninety oxmysql queries in onResourceStop and expecting ninety callbacks back is optimism, not architecture.

Treat the shutdown handler as a bonus round. Your actual protection is that the interval save ran ninety seconds ago. The one place you get a genuine window is a planned restart: txAdmin announces those ahead of time and emits txAdmin:events:scheduledRestart as the countdown runs, giving you a controlled minute to flush everyone in batches instead of racing a teardown you will lose.

Transactions, idempotency, and the item that exists in two places

Now the famous one. Watch a duplication bug happen without anybody exploiting anything:

  1. A player drags a gold bar from their inventory into a shared stash.
  2. The stash resource writes immediately, because stashes are shared and must be correct for whoever opens them next.
  3. The player’s own inventory is still in memory, waiting on the next interval save.
  4. FXServer dies in the gap between those two writes.
  5. The server returns. The stash has the gold bar. The player’s row also has it, because its last committed state predates the move. One gold bar in, two out.

No cheat engine, no packet injection. The dupe is what the data says, and it happens to a slice of your players every time you crash mid-evening. Two fixes stack here, and you want both.

First, transactions. Any move touching more than one row must land as one unit or not at all. oxmysql gives you that directly with a list of queries that commit and roll back together.

local ok = MySQL.transaction.await({
    { query  = 'UPDATE players SET inventory = ? WHERE citizenid = ?',
      values = { json.encode(inv), citizenid } },
    { query  = 'UPDATE stashes SET items = ? WHERE stash_id = ?',
      values = { json.encode(stash), stashId } },
    { query  = 'INSERT INTO item_log (citizenid, item, amount, direction, ref) VALUES (?, ?, ?, ?, ?)',
      values = { citizenid, 'gold_bar', 1, 'to_stash', ref } },
})

A vehicle sale is the same shape with more parts: buyer’s money down, seller’s money up, ownership on player_vehicles reassigned, log row written. Four statements, one commit. Run them independently and a crash halfway leaves a car with two owners or none, plus money that doubled or evaporated. One caveat bites people here: this only works on InnoDB. MyISAM accepts every one of those statements happily and rolls back precisely nothing, so check the engine before you trust a transaction to protect anything.

Second, idempotency, a save that is safe to run twice. Retries are not hypothetical: queries time out and get retried, webhooks fire duplicates, admins run a recovery script twice because they were not sure it worked. Prefer absolute state over deltas. SET money = 4200 applied twice still gives 4200, while SET money = money + 500 applied twice gives you a support ticket. Where you genuinely need a delta, attach a unique mutation id and make it the unique key on your log table, so a repeat lands as an ignored insert instead of a second gold bar.

The companion trick is a version integer on the player row that you bump on every save and check in the WHERE clause. A stale write then matches zero rows and does nothing instead of stomping newer data. Players reconnect within seconds of a crash, and a queued save from the old session landing on the freshly loaded new one is exactly how somebody loses an hour twenty minutes after you thought the incident was over.

When the save is gone: rebuilding from your logs

Sooner or later you lose one anyway. A row is a session behind, a stash got wiped, a car is missing. The only question is whether you can reconstruct it or whether you are guessing from a screenshot the player may have cropped.

You can only rebuild what you wrote down, which means logging the mutation rather than the outcome. “Player 42 bought a vehicle” is a sentence. A record carrying the citizenid, what moved, how much, in which direction, the before and after values, a reference id matching the row you wrote, and a timestamp with seconds on it is something you can replay. Log the citizenid rather than the server source, since sources get recycled within minutes and you will end up attributing somebody’s Sultan to an innocent bystander. This FiveM server logging guide covers the transport and retention side properly.

With that in place, recovery is a procedure rather than an argument. Find the player’s last known-good state in the database or your most recent dump. Pull every logged mutation for their citizenid after that timestamp. Replay them in order while the player is offline, so nothing races your fix. Then write a recovery entry of your own, so that if somebody runs it a second time you can tell.

Underneath that, MySQL has its own net if binary logging is on: point-in-time recovery replays a backup plus the binlog up to a chosen moment. It restores the whole database though, so it is the right tool for “somebody truncated a table” and the wrong one for “Dave lost his Sultan”.

The version of this you can ship this week

If your server saves on disconnect and nothing else, you do not need to rewrite your framework. Three changes get you most of the way.

  1. Add an interval save with a randomised first deadline and a per-sweep budget. That alone caps your worst case at minutes instead of a whole session.
  2. Add dirty flags so that interval save is cheap enough to run often without the database noticing.
  3. Wrap every inventory move and vehicle transfer in a transaction, and confirm those tables are InnoDB before assuming the transaction does anything.

Our Sultan guy still loses a couple of minutes to a hard crash, because everybody does. No amount of good fivem data persistence work survives a power cut at the precise instant of a write. What changes is that he keeps the car, the garage and the vehicle table tell the same story, and exactly one gold bar exists in the world. The only ticket waiting at 11:41pm is somebody asking where his hat went.