Datastore WORST Practices

Because a little pessimism never hurt anyone.

by monoduality

Author Avatar

Metadata

This article is written on 7/7/2023. It has been last updated on 19/9/2023.

Contents have been vetted to be accurate up till the date of the update as mentioned above.

This guide was written for all skill levels. For a summary, see the bolded text for each section. For feedback, feel free to post it in the comments below.


Glossary


Overview

In my years of Roblox development, I have personally seen countless tutorials and user-uploaded code (or request for help with their code) regarding datastores.

While I can go down the same route as plenty of others have by giving readers a comprehensive tutorial regarding datastores, I think it would be far more interesting if I gave a slightly pessimistic cautionary tale.

Written almost entirely by (collective) experience, I hope that this guide would push people away from my footsteps and avoid making the mistakes that others and I have made.


1) Underestimating the need for good datastore code.

Progress is the main factor behind good persistent engagement by your players - making tangible progress in a game is what drives your players to continue playing your game.

Conversely, no one likes losing their progress for any reason. Good datastore code is what protects the progress made by your players, and subsequently motivates players to keep playing your game.

The magnitude of this becomes drastically bigger when you mix datastores with paid products - this is when you have to be BEYOND CAREFUL with how you script datastores.


2) Not understanding datastore limits, part 1.

To prevent spam, all calls to datastores such as datastore:GetAsync() and datastore:SetAsync() count towards a server-wide limit, changing depending on the type of call made.

If you hit a given limit, your subsequent calls will be put into a queue. The queue will throttle the calls placed within, which may cause a delay before which they are executed.

If you hit the queue's limit however, subsequent calls will be dropped and they will throw you an ERROR instead, which may result in a failure to load or save data, or perhaps worse.

The current aforementioned limit for the queue is 30 - if you make another call if the queue is still stuck with 30 calls it hasn't executed yet, that call errors instead.

You can't get around the limits, but you can limit the data and the frequency of which you are storing and sending data. There's another technique that will help achieve this, but that will be discussed in another section.

These are the limits of datastore calls, sorted by call type:

60 + (numPlayers × 10)

5 + (numPlayers × 2)

On top of that, datastore calls are also limited by how much data you can send per minute, detailed as such:

25 MB (25 million characters) per minute

4 MB (4 million characters) per minute


3) Not understanding datastore limits, part 2.

All datastore entries also have a data size limit, exceeding which would cause an error. With sensible naming and updating of values however, hitting these limits would hardly be realistic.

The maximum characters you can store in each part of the datastore are:

50 characters

4,194,304 per key

Datastore, key and scope names are stored as strings, whose character lengths can be checked using with the following methods:

print(#"This is a string") -- 16
print(string.len("This is a string")) -- 16

Datastore data are also saved as strings - if they are not already a string, they will be internally converted into strings first before saving.

To check the character length of your table of data is a tad bit more complicated:

local resultString = game:GetService("HttpService"):JSONEncode({
    userid = 1,
    cash = 0,
    pets = {},
    receiptinfo = {},
}) -- returns a string.

print(#resultString) -- 48

4) Not wrapping datastore calls using pcall().

ALL datastore calls, be it SetAsync() or ListKeysAsync(), are HTTP calls - they have a chance to be dropped for any reason, such as ping, connection failure, or a lack of response from the datastore service. Dropped datastore calls, as mentioned earlier, will result in an error.

It is wise to wrap all such calls in a pcall(), as it gives you an avenue to handle cases where data cannot be loaded or saved. More importantly, it prevents a potential error from stopping your script from running.

To use them is easy!

local success, result = pcall(function()
    return DataStoreService:GetAsync(player.UserId)
)

-- success will either be true or false, depending if the function was executed properly.
-- if it ran without errors, result will be the datastore data.
-- otherwise, result will be the error message.

print(success, result)

5) Not using game:BindToClose().

Servers can shut down for a multitude of reasons, which will cause game:BindToClose() to fire.

Not saving data for all players in a BindToClose() means these things (all of which results in data loss):

The best practice in this case, is to save data in a BindToClose() event as well, since this almost guarantees that a player's data will be saved even in those edge cases above.

game:BindToClose(function()
    for _, player in Players:GetPlayers() do
		local _, _ = pcall(function()
			datastore:SetAsync(player.UserId, data[player.UserId])
		end)
    end
end)

6) Not using a cache.

Caching data in this case means to "save" whatever data belonging to a player into a container like a ModuleScript, assuming it is put into ServerStorage for security reasons.

Some benefits to this include:


7) Not serializing data.

Datastore data only allows primitive datatypes, such as strings, booleans and numbers.

To save a datatype such as Vector2, Color3, and others, serialize them in a format that the datastore would understand. The simplest way to do this is to convert them into arrays to be saved - when they are loaded, simply get those data and create the needed value.

-- for brevity, pcalls() will not be used here.
-- all datastore calls are also assumed to run without error.

-- to "save" a color:
local ineligible = Color3.new(1, 1, 1) -- this cannot be saved into datastores...
local eligible = { ineligible.R, ineligible.G, ineligible.B } -- but this can!

datastore:SetAsync("color", eligible)

-- to create a color with the saved values:
local result = datastore:GetAsync("color")

local color = Color3.new(result[1], result[2], result[3])
-- OR
local color = Color3.new(table.unpack(result))

This extends to the data itself; to conserve key space, you may wish to discard dictionary keys, so that the pending data would become an array.

This has to be done within reason however, as the resulting table might be unreadable and too difficult for you to deserialize when you get the data back.


8) Not reconciling data.

First-time users will always start with no data (nil) in datastores. If you try to get data from a first-time user the normal way, you'll end up getting nil instead.

To solve this, have a set of default data values that you can hand out to players; or statements are your best friends in this case.

-- for brevity, pcalls() will not be used here.
-- all datastore calls are also assumed to run without error.

local data = datastore:GetAsync(userid)
local CashValue = data.cash or 0 -- if data.cash is nil, set it to 0 instead.
local ExpValue = data.xp or 50 -- if data.exp is nil, set it to 50 instead.

This may also help in cases where the player's data does exist in datastores, but cannot be loaded for any reason.


9) Not session-locking data.

Datastore calls may introduce a (not so) subtle bug where a player's data is loaded first, even if the game is still busy saving the data for that player's previous gaming session.

This is primarily the reason why duplication bugs exist in Roblox games, and is formally known as a race condition, an inherent problem with all HTTP calls.

While this may seem unrealistic on the surface, the problem becomes obvious in cases like:

A way to solve this issue is to bind the server's ID to the player's data whenever they load into the server, using game.JobId, and to set it to 0 if the user's data is done saving.

local dataset = {
	serverid = game.JobId

	cash = 0,
	xp = 50,
}

If the player loads in while serverid is not 0, you'll know that the player's data has not been saved yet, and you can block the load call and handle the player's game session accordingly.


10) Using SetAsync() and GetAsync().

If you've actually read up to this point (and without doing a TL;DR):

If you have actually got to this point without trouble, I genuinely believe you have the technical skills to understand what's next.

While GetAsync() and SetAsync() are sufficient for most use cases, they have their own issues that may manifest themselves as problems that I have talked about in the above section.


To solve these headaches, I strongly recommend using UpdateAsync() in substitute of all GetAsync() and SetAsync() calls, for the following benefits:

When used in conjunction with session locking, dupe bugs become next to impossible to achieve. I promise.

To get and set data using UpdateAsync() is actually pretty easy. If it helps you out, here's some sample code:

-- for brevity, pcalls() will not be used here.
-- all datastore calls are also assumed to run without error.

-- to get data:
local result = nil
datastore:UpdateAsync("key", function(old)
	result = old
	return nil
end)

-- to set data:
local new = {}
local result = datastore:UpdateAsync("key", function(old)
	return new
end)

-- to cancel the write operation:
local result = datastore:UpdateAsync("key", function(old)
	return nil
end)

If you only intend to get the latest data, be sure to cancel the write operation, to save on bandwidth and time.

UpdateAsync() does not work for OrderedDataStores.

UpdateAsync() is also most effective if you're saving data in tables.


Closure

Note: This section is not sponsored, although I will be glad if it is.

If all these sound too scary for you to implement alone, ProfileService helps by handling all that for you, and has already seen use in many games, big or small.

This is basically the only library I recommend these days, and for good reason. If anything, many pointers that I have discussed here are things that ProfileService has already done for you, so that you won't have to.

Give it a shot sometime.

Credits go to loleris for the making of ProfileService.

View in-game to comment, award, and more!