If you've ever built an app with Realtime Database you know that it's fast. When you combine the low-latency websocket connection with the local caching capabilities of the SDK, changes can feel pretty much instantaneous.
But have you ever wondered how fast your database operations are for your users in the real world? As a good app developer you need to collect real-world performance data to make sure that the experience of using your app in the real world matches your expectations! Many people in the tech industry call these field measurements Real User Monitoring (RUM) and they're considered the gold standard for measuring app performance and user experience. Firebase Performance Monitoring is a free and cross-platform service to help you collect and analyze RUM data for your app or website.
Firebase Performance Monitoring automatically measures common metrics like time to first paint and HTTP request performance. Because Realtime Database uses a long-running WebSocket connection rather than separate HTTP requests we'll need to use Custom Traces to monitor the performance of our database operations.
For this post we built a Firebase-powered implementation of the standard TodoMVC app in React using the ReactFire library:
Each time we add, update, or remove an item in our to-do list we're making a change in Realtime Database directly. For example here's the code to add a new todo item:
function App() { // Get an instance of Firebase Realtime Database using the 'reactfire' library const db = useDatabase(); // Load all the 'todos' from the database const todosRef = db.ref("todos"); const list = useDatabaseList(todosRef); // ... // Add a new todo to the database const handleAddTodo = (text) => { todosRef.push({ text, completed: false, }); }; // ... }
This operation appears to be instantaneous because the Realtime Database SDK immediately adds the new todo to the local listener while it waits for the backend to acknowledge, or reject, the write. But what if we want to find out how long it actually takes to commit the write on the server?
Let's add some code to measure how long this really takes. We'll create a new function called tracePromise to help us log a custom trace for any action which returns a Promise and then we'll add a simple custom trace called add-todo.
tracePromise
Promise
add-todo
function tracePromise(trace, promise) { trace.start(); promise.then(() => trace.stop()).catch(() => trace.stop()); } function App() { // Get and instance of Performance Monitoring using the 'reactfire' library const perf = usePerformance(); // ... const handleAddTodo = (text) => { const p = todosRef.push({ text, completed: false, }); // Use the 'tracePromise' helper to see how long this takes const trace = perf.trace("add-todo"); tracePromise(trace, p); }; // ... }
If we deploy this code and head to the Firebase console we can see that the "add-todo" operation takes about 100ms in most cases, with 160ms being the worst case.
If we break this down by country we can see that the operation is much faster for users in the US than in other countries:
This makes sense! Most Realtime Database instances are located in the United States, which can have an impact on latency for users around the world. Geographic latency increases can depend on physical distances as well as the network topology between two points.
We don't often think about it when coding but data can only travel at the speed of light! For two points on opposite sides of the earth the speed of light alone adds 66ms of latency, and that's not including any of the actual network or processing latency along the way. This is why adding RUM to your app is so critical.
Well, the good news is that Realtime Database is now expanding to more regions around the world, beginning with the launch of our Belgium region in late 2020. A todo list app lends itself really well to sharding because each user's data is exclusively their own. So let's add a second Realtime Database instance to our app in the Belgium region, and assign each user to a random database instance to see what effect that has on our latency:
First we'll add a custom attribute to our Performance Monitoring traces so that we can filter the data by location later:
function getMyLocationCode() { // User's location could be stored in a URL param, cookie, localStorage, etc. // ... } function tracePromise(trace, promise) { // Add a custom attribute to the trace before starting it const location = getMyLocationCode(); trace.putAttribute("location", location); trace.start(); promise.then(() => trace.stop()).catch(() => trace.stop()); }
Now let's deploy these changes and wait for new user data to come in. After a few days we can see that our experiment worked! First we can see that our distribution now has two obvious peaks:
This is what we expected, because we're now randomly assigning users to one of two database instances. Depending on the one they get, it will either be close to them or far away.
If we look into the data more, we can see that our German users have a really fast connection to the Belgium instance! They're getting updates in as little as 22ms. That's a huge improvement over the 150ms+ they were getting when communicating with an instance in the US. While the local caching in the Firebase SDK will make the UI feel snappy either way, this will make a huge difference in the speed of collaborative or multi-device scenarios.
With this RUM data in hand we can be confident that adding a new database region can make our app faster. Next we'll need to find a way to detect the user location when they sign up and assign each account to the best region for them. For now we'll leave that as an exercise for the reader!
If you're ready to get started measuring performance in your own app, check out these links:
Version 7.1.0 of the Firebase Unity and C++ SDKs did a lot to improve Firebase Remote Config and better aligned it with the iOS and Android SDKs. In the process, however, there were some minor changes to the API that may require some action on your part.
First, in the Unity/C# SDK, the static methods you’ve been using have been removed from FirebaseRemoteConfig and moved into FirebaseRemoteConfigDeprecated. For technical reasons, we could not deprecate these functions and leave them in place. If you want a fast and easy way to update Firebase in your game, change FirebaseRemoteConfig to FirebaseRemoteConfigDeprecated. This will let your existing code work without any additional changes, but you won't be able to take advantage of the newer features.
FirebaseRemoteConfig
FirebaseRemoteConfigDeprecated
Also worth noting is that IsDeveloperMode no longer has any effect and can be omitted. You likely paired this with a change to MinimumFetchInterfaclInMilliseconds or by passing a TimeSpan into FetchAsync when debugging Remote Config. Shorter fetch intervals now function without IsDeveloperMode making RemoteConfig an even stronger tool in your LiveOps toolbox!
IsDeveloperMode
MinimumFetchInterfaclInMilliseconds
TimeSpan
FetchAsync
C++ developers can leave their code as is, although you will be warned about deprecation.
Developers wishing to future proof their code and take advantage of new features as they become available should switch to using a Remote Config instance rather than using static functions.
Unity/C#
var remoteConfig = FirebaseRemoteConfig.DefaultInstance;
C++
auto remoteConfig = ::firebase::remote_config::RemoteConfig::GetInstance(app);
SetDefaults is now asynchronous. This is probably a good thing for game developers hoping to move logic off of the main thread, but you do have to be a little more cautious when building your app logic.
remoteConfig.SetDefaultsAsync(new Dictionary<string, object> { {"starting_player_lives", 3}, {"theme", "dark"} }).ContinueWithOnMainThread(task => { // it's now safe to Fetch });
std::vector<remote_config::ConfigKeyValueVariant> defaultConfig = { {"starting_player_lives", 3}, {"theme", "dark"}, }; remoteConfig->SetDefaults(defaultConfig.data(), defaultConfig.size()) .OnCompletion([](const ::firebase::Future<void>&){ // it's now safe to Fetch });
It’s now possible to Fetch and Activate Remote Config changes in a single call.
remoteConfig.FetchAndActivateAsync().ContinueWithOnMainThread(task => { // handle completion });
remoteConfig->FetchAndActivate().OnCompletion([](const ::firebase::Future<bool>& future) { // handle completion });
And when you are ready to read your configuration, it’s now possible to retrieve all of the keys and values in a single language-specific native collection. No more getting a list of keys and looking up each value individually!
IDictionary<string, ConfigValue> values = remoteConfig.AllValues;
std::map<std::string, ::firebase::Variant> values = remoteConfig->GetAll();
Finally, if you would like to debug your Remote Config settings, you may want to reduce the interval between fetches.
var configSettings = new ConfigSettings(); if (Debug.isDebugBuild) { // refresh immediately when debugging configSettings.MinimumFetchInternalInMilliseconds = 0; } FirebaseRemoteConfig.DefaultInstance.SetConfigSettingsAsync(configSettings).ContinueWithOnMainThread(task => { // handle completion });
::firebase::remote_config::ConfigSettings settings; settings.minimum_fetch_interval_in_milliseconds = 0; // caution, only do this in debug mode! remoteConfig->SetConfigSettings(settings);
These changes are part of an ongoing effort to improve Remote Config, including some exciting new features you'll be hearing about in the future. Updating your games now will ensure that you can continue to take the advantage of the latest advances in Remote Config.