Roblox GuidebookKnowledge base
English ⌄

Development / ROBLOX

Roblox UpdateAsync: why two saves can lose one change

Two fictional workshop servers each read 100 and add 5, yet the stored total ends at 105. Follow the difference between replacing a stale value and transforming fresh state, then test a small pure function without connecting to a live data store.

Updated:

Investigate the sequence before blaming the save button #

Imagine a shared teaching counter for completed deliveries. Two independent handlers should each add five. Both report that their work finished, but the total increases only once. This can happen when both proposals were calculated from the same old snapshot. The symptom alone does not establish the cause in your game: first record the actions and conditions surrounding each write.

This guide follows the first-save tutorial but addresses a different problem: a new value depends on state another server might change. We use an isolated teaching counter, not a player's wallet, purchase record or complete profile. Every number and schedule below is fictional. We did not launch two real Roblox servers to produce these examples.

Draw the stale-write timeline #

Write five rows: storage contains 100; handler A reads 100; handler B reads 100; A writes 105; B writes its own 105. B does not need malicious intent to lose A's change. It simply replaces the record with a proposal calculated before A's write. Two additions should produce 110, whereas two identical replacements leave 105.

Keep this timeline beside the task. It distinguishes a handler that never ran, a failed network call and two successful writes based on stale state. For a local exercise, log the operation name, a fictional task identifier, its input and its proposed result. Private player information and credentials are unnecessary to demonstrate this problem.

Stale snapshots lose a changeOpen full-size image ↗
Fictional replacement timeline, explaining a lost change; not a real server log.
StepActionStored value
1Start100
2A reads 100100
3B reads 100100
4A writes 105105
5B writes 105105

Replacement and transformation express different intentions #

SetAsync sets a key's value without first reading it within that method. This fits replacing a record with a known value that does not depend on the old state. Adding to the current counter requires a different basis. Roblox recommends UpdateAsync when a write depends on the current value or multiple servers may write the same key.

Changing the method name while returning a previously calculated 105 from the callback does not fix the example. The proposal still comes from the stale snapshot. Calculate from the current argument supplied to the callback. Explain that distinction before expanding the example to an inventory table: proposing a transformation of fresh state differs from resubmitting your earlier snapshot.

The callback may run again #

If another server changes the key between reading and committing, UpdateAsync can discard the previous proposal and call the transformation again. One method call therefore does not imply one callback invocation. In our fictional schedule, A proposes 105, B commits 105 first, and A recalculates using 105 to propose 110.

A repeated transformation should not grant an item again, send a reward event or mutate an external gameplay object. Its job is to calculate the proposed record. Gameplay effects need a separate decision based on confirmed state. Moving an item grant after a successful call still does not establish that another handler cannot independently request the same business operation again.

Recompute using fresh stateOpen full-size image ↗
Fictional recomputation after another writer. A local model does not verify actual Roblox network behavior.

A teaching function without hidden state #

Our CounterTransform ModuleScript exposes Add(current, delta). It treats a missing counter as zero, accepts whole nonnegative values and a positive increment, and limits the result to one million. These are chosen rules for this counter. Another game's initialization and valid range may differ; they are not universal rules for player profiles.

The function returns a new number without changing external state. Identical inputs produce identical outputs. Malformed values, a negative or fractional increment and exceeding the chosen limit return nil. There is no network request, wait, reward grant or player access inside it. We ran local Luau tests of this function; those tests do not verify Roblox's actual storage behavior.

-- Pure transform for an isolated teaching counter, not a player profile.
local Transform = {}
local LIMIT = 1000000

local function validInteger(value)
    return type(value) == "number" and value == math.floor(value)
        and value >= 0 and value <= LIMIT
end

function Transform.Add(current, delta)
    if not validInteger(delta) or delta == 0 then return nil end
    if current == nil then current = 0 end
    if not validInteger(current) then return nil end
    if delta > LIMIT - current then return nil end
    return current + delta
end

return Transform

Connect the transformation in an isolated test #

Place CounterTransform in ServerScriptService in a separate test experience. A server Script obtains DataStoreService, selects a teaching-counter store and requires the module. Give UpdateAsync a callback that returns CounterTransform.Add(current, 5). Do not place task.wait, resource loading or another yielding operation inside the transformation.

The UpdateAsync call itself is a network operation. Wrap it in pcall and separately inspect the returned value. A successful pcall means no error was caught; if the callback cancelled with nil, it does not prove five was saved. Naming a store as a test store does not isolate it automatically when the experience and keys still contain production data.

-- Server Script in an isolated test experience only.
-- This demonstrates one numeric teaching key, not a player profile.
local DataStoreService = game:GetService("DataStoreService")
local CounterTransform = require(
    game:GetService("ServerScriptService"):WaitForChild("CounterTransform")
)
local store = DataStoreService:GetDataStore("GuidebookCounterExercise_v1")

local ok, result = pcall(function()
    return store:UpdateAsync("CounterExercise", function(current)
        return CounterTransform.Add(current, 5)
    end)
end)

if not ok then
    warn("Write response failed; outcome is not established", result)
elseif result == nil then
    warn("Update cancelled; no new saved counter was returned")
else
    print("Updated teaching counter", result)
end

Cancellation must not become a silent reset #

Returning nil from the callback cancels the update. In this exercise that is useful when the stored type is unexpected or the proposed number exceeds the limit. Do not replace a malformed string with zero just to keep the route moving. That can hide a data problem and overwrite a record requiring investigation. A missing record and an invalid existing record are different cases.

For diagnostics, record that the result did not provide the expected saved number and stop further steps of this teaching route. Our compact pure function does not supply detailed rejection reasons. If your design needs such a journal, ensure that repeated callback execution does not become another gameplay action or alter the basis of a later calculation.

Test the model and the function separately #

First reproduce the bad schedule with two snapshots of 100, two proposals of 105 and two replacements. Then model a fresh-value recomputation: prepare A's proposal, apply B's change, discard A's old proposal and calculate again from 105. The final result should be 110. This is a deterministic model of two schedules, not an emulator of all Roblox internals.

Also test a missing value, an ordinary integer, a string, a fraction, a negative number, infinity and the upper boundary. Repeating identical inputs must not increase the result through hidden state. Passing a table instead of a number must leave that table unchanged. The local test checks those specific properties, not production-server resilience.

Local checkExpected result
Missing value; add 55
Current 100; add 5105
Current 105; add 5110
String instead of numbernil: cancel
999995; add 51000000
999996; add 5nil: cancel

Callback repetition is different from operation repetition #

Repeating the callback within an update reconciles a proposal with changed state. A second independent request to add five is another operation: it can add another five even when UpdateAsync is used. The method is consequently not a complete defence against duplicate rewards, repeated orders or repeated purchase processing.

That business problem needs a separately defined operation identity and a rule for checking its completed state alongside the intended data change. We do not implement that mechanism here. A scalar counter has no operation history. Give the next development chat this limitation with the code so that the demonstration is not mistaken for a finished wallet system.

An unsuccessful response leaves another question #

A network-call error does not automatically establish that the backend wrote nothing. Roblox documents writes with unknown outcomes: a server can miss a successful response after a write completed. An unconditional new add-five request could then repeat the independent operation. That situation differs from the method's internal callback recomputation.

Do not add an endless retry loop to the example. For a real system, first define unknown-outcome handling and the order of operations for each key. Then choose bounded retries for transient failures and suitable delays. Our local test does not cause a real Roblox network failure or prove a solution to that problem; it remains a separate design task.

One method does not implement a complete profile #

A complete profile can contain related fields, session ownership and metadata. A single-number example does not demonstrate how to preserve them while changing a record. It does not assign a session owner or stop an older server saving its snapshot after the player has moved to a newer server. Calling those tasks solved by UpdateAsync would conceal unfinished work.

Do not use this teaching counter for Robux purchases or a real game currency. A production adaptation requires its own schema, compatibility checks, failure handling and reward requirements. Start with Roblox's player-data and purchasing guidance, then verify the particular implementation. This narrow experiment explains transformation rather than providing a profile-management library.

Keep an honest development handoff #

Share both timelines, the isolated key name, the numeric rules and the completed local checks. Explicitly state that real servers and network contention were not tested. List the unresolved requirements separately: unknown write outcomes, repeated business operations, session ownership and preservation of other profile fields.

When comparing implementations, ask which value each proposal is based on and which effects occur outside the callback. The answer should withstand a concrete schedule. If it only works without a second writer, the conflict problem is still unresolved. Keep this guide with the first-save tutorial to distinguish initial data-store access from coordinating subsequent updates.

Original sources

Roblox Creator Hub — Data stores
Roblox Creator Hub — GlobalDataStore
Roblox Creator Hub — Data store best practices