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.
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.
| Step | Action | Stored value |
|---|---|---|
| 1 | Start | 100 |
| 2 | A reads 100 | 100 |
| 3 | B reads 100 | 100 |
| 4 | A writes 105 | 105 |
| 5 | B writes 105 | 105 |
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.
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 check | Expected result |
|---|---|
| Missing value; add 5 | 5 |
| Current 100; add 5 | 105 |
| Current 105; add 5 | 110 |
| String instead of number | nil: cancel |
| 999995; add 5 | 1000000 |
| 999996; add 5 | nil: 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 storesRoblox Creator Hub — GlobalDataStore
Roblox Creator Hub — Data store best practices