Get microsecond read latency on AWS Lambda with local databases

Showcase of an app that stores your SQL data locally in AWS Lambda allowing you to do local disk reads instead of costly network roundtrips.

Cover image for Get microsecond read latency on AWS Lambda with local databases

#Local database in your Lambda

AWS Lambda is a go-to serverless compute service for lots of users. Now that it also supports ephemeral storage up to 10GiB, could SQLite files be used to make database access much faster?

In this post we'll show how to use Turso to achieve just that.

#Embedded replicas

Our service, Turso, brings libSQL, our growing open-contribution SQLite fork, to the serverless world. Traditionally, Turso focused on making SQLite accessible over HTTP for environments where a storage device is not available. Recently we launched embedded replicas, which let you replicate your database locally and keep it in sync for fast reads, where a storage device is available.

With embedded replicas, your data is synchronized with the remote servers as often as you wish, and your writes are forwarded as well.

#AWS Lambda and use cases

Embedded replicas are usually a great fit for server environments. By replicating the data into the local filesystem, you can achieve microsecond-level read latency. AWS Lambdas, however, are usually thought to be stateless. That is, however, not true: there is a temporary filesystem available for the function, and functions will tend to be executed in the same host, making it a warm lambda.

Lambda's concurrency determines how many hosts are needed for their execution. A function with high concurrency will need many physical hosts to execute, increasing the probability that any one particular host will be deemed idle, and retired.

If the data is small enough, so that downloading the dataset at the beginning of the function's execution is acceptable (in practice, 10MB should add only a couple of hundred milliseconds to the cold start time), and the concurrency is low enough so that the chance of the lambda being hot is high, replicating the entire dataset into the lambda is a great strategy. Otherwise, sticking to the HTTP is the best route.

There are a variety of datasets that fit this description. One example is configuration data, which power use cases like A/B testing, feature flags, IP whitelisting, etc.

For comparison, Vercel Edge Config allows fast access to configuration data up to 512kB. With databases on that size range, Turso users would have almost unnoticeable cold starts, and have full SQL access to their data.

#App for stalking your friends

In our example, let's get loosely inspired by the early days of Discord and implement a system for tracking what your friends play at the moment, and when they were online last.

#Setup

If you don't already have Turso set up, follow our tutorial. Our free tier is plenty enough to host this example application and more. You're also going to need an AWS account capable of creating and running Lambda instances – the official guide is a good starting point.

For this example, we will be using the node.js runtime for AWS Lambda.

#Design

Let's keep the app simple. The part implemented in AWS Lambda is going to be a service that allows you to list information on all players, as well as send an update for a specific player. It will be served via HTTP.

The data will be stored in the following format:

CREATE TABLE users(
    username TEXT PRIMARY KEY,
    last_seen INT,
    playing TEXT
);

#Backend

We have a functional libsql package that supports embedded replicas. It's also really easy to set up! Once you create your Turso database URL, you just open a connection like that:

const db = new Database("/tmp/local.db", {
    syncUrl: "https://your-turso-db-link.turso.io",
    authToken: "your-token-obtained-with-turso-db-tokens-create-command",
});

and it will store the data locally, while being able to sync with the remote database as its source of truth.

Let's start by creating a default app and adding our libSQL package as its dependency:

npm init
npm install libsql

In order for your code to be callable by AWS Lambda machinery, it's enough to just expose a single handler function that accepts an event. This event object has access to the query parameters, and that's all we really need to either read the state:

SELECT username, last_seen, playing FROM users

, or update it with new information on some player:

INSERT INTO users(username, last_seen, playing) VALUES (?, ?, ?)
    ON CONFLICT(username) DO
        UPDATE SET
            last_seen = excluded.last_seen,
            playing = excluded.playing

But how do we know if the state is up-to-date? That's the cool part: when we call db.sync(), Turso will replicate the remote database to Lambda's local ephemeral disk. After it's done, all read operations can be done locally. This ephemeral volume is preserved between calls as long as the lambda stays warm, which means that its execution environment is still available. Data about who plays what does not need to be continuously refreshed, so the application only schedules a new sync if the data is either not available (which is the case when the lambda cold starts), or when the data is there, but wasn't refreshed in the last 30 seconds. In all the other cases, data can be read straight from the local volume instead of the network, and that takes just a few microseconds.

The whole source code is here: https://github.com/psarna/nowplaying, enjoy!

#Latency

How much do we gain by storing the data locally, right where all the compute logic happens?

Disclaimer: I deployed the lambda as close to me as it gets – eu-central-1 datacenter in Frankfurt. Your mileage may vary, and I didn't explore Lambda@Edge yet due to its limitations compared to regular Lambda.

On cold start, when the database needed to be synchronized locally, the page was served in around 200ms:

And the consecutive calls which had the database at hand locally, just performed a local read and served the page in ~45ms!

That is the full time of the whole API call. The database call, at this point, was resolved internally in a matter of microseconds.

For comparison, here's the timing I get for a “noop” lambda, which doesn't read from the database at all, and instead just gives you a hardcoded result.

#Frontend

I'm by no means either a backend or frontend developer, so I'm going to cut corners here and use a single static HTML page to just call our lambda from there. It prints the list of active players, as well as a simple form to update an entry.

#Deployment

To deploy your Lambda, you can either click through the AWS web interface, or be a reasonable person and use the aws CLI:

npm prune --omit=dev
zip -r nowplaying.zip .
aws lambda create-function --function-name nowplaying --zip-file \
    fileb://nowplaying.zip --handler index.handler --runtime nodejs18.x

aws lambda update-function-configuration --function-name nowplaying \
    --ephemeral-storage '{"Size": 512}'

aws lambda update-function-configuration --function-name nowplaying \
    --environment "Variables={LIBSQL_SYNC_URL=$YOUR_DB_URL,LIBSQL_AUTH_TOKEN=$(turso db tokens create $YOUR_DB_NAME)}"

Here's how the deployed app looks! https://nowplaying.sarna.dev

#What now?

What do you mean “what now”, go bring your database where your compute is, with Turso!

scarf