u/Prestigious-Type-973

Making cache with DynamoDB 6.7x faster

This is a story about how I identified a slow DynamoDB cache in my Laravel app and reduced its latency from 20ms to 2ms.

---

A few disclaimers:

  • Topic researched and prepared by a human (me);
  • Formatted by AI;
  • Originally, I was preparing this article for a third-party engineering blog, but it didn’t work out, so I thought, well, since I already have the material - let's make it useful anyway.

---

Caching is one of the most effective techniques for improving application performance: instead of recomputing an expensive value on every request, you return an already-computed result from a fast data store. Laravel ships with several cache drivers for this: array (in-memory), apc, redis, database, and one of them being DynamoDB.

DynamoDB is a powerful, scalable, globally available data store. In particular, AWS advertises single-digit-millisecond latency for read operations, given an appropriately designed schema. It’s tempting to read that and assume that simply using DynamoDB gets you this “global storage” cache with single-digit (<10ms) latency across the application, immediately, out of the box.

There’s a catch.

The gap between the promise and reality

Once DynamoDB has been created and configured as a cache “out of the box,” the minimum latency you’re likely to see for a single, cold request to read or write data is around 25ms. In some cases it might be up to 50ms. In badly configured setups, it can take up to 100ms, which is a lot to pay for a single cache read.

Compare that to the alternatives: Redis can provide latency around 500µs (less than 1ms), and even the local filesystem can serve a cache read in sub-millisecond. So what makes DynamoDB such a slow cache by comparison? And what’s the bare minimum we could actually get out of it? Spoiler: 2ms - significant improvement over the “out of the box” floor.

Understanding the request cycle

First, we need to understand how PHP (Laravel) interacts with DynamoDB. It starts with establishing the HTTP connection; for every request, DynamoDB uses HTTP as its communication protocol, which is much “heavier” than what MySQL or Redis use on top of the TCP (layer 4) layer. DynamoDB operates at the highest possible layer of the OSI model (layer 7), and most of the cost of using DynamoDB comes from this protocol.

Knowing this, the timeline to get data from DynamoDB has two major parts:

  1. Establishing the HTTP connection to DynamoDB, including DNS resolution, TCP handshake, TLS handshake, and sending the request itself.
  2. Processing the request by DynamoDB - the time DynamoDB itself takes to authenticate, execute the read/write, and send back a response, once the request has actually arrived.

In some cases there’s one more step at the very beginning: obtaining an access token. It’s quite fast, but can still take a few milliseconds. If we run the tests, both major parts are roughly 10ms each under “normal” conditions, which makes 20ms the floor - the very minimum we could get. In practice, it will often be higher.

DynamoDB's server-side latency floor

DynamoDB's own response time factors into that floor too, even though tuning it isn't the focus here. What keeps it fast: the right schema, indexes, properly configured read/write capacity, autoscaling, and so on; each a deep topic on its own, including the specifics of the cache table's own schema. Assume the best case on DynamoDB's side for this article: 1ms, realistic under good conditions. AWS promises <10ms, but under ideal conditions it can genuinely be just a few ms.

The network part

Now, about the network. Establishing a connection is a fairly expensive operation, especially for highly available, performant systems (APIs), and in some cases we simply can’t afford it. Under normal conditions, we have to establish a new HTTP connection to the DynamoDB server for every single incoming (user) request. This costs time and resources.

Luckily, PHP allows us to open a connection once and reuse it multiple times. This already happens within a single process (prior to the 8.5 release): when we make consecutive requests, we’re reusing the already-established connection. But when there are multiple instances of our application (or, more precisely, a fresh client gets constructed on every incoming request, which is what actually happens in a typical Laravel app, if Octane is not being used), each one establishes its own new connection to DynamoDB for every new user request.

This can be improved with persistent connections (added in 8.5) that get shared across requests, eliminating the need to redo the “expensive” DNS lookup and TLS handshake every time a new user request comes in. This reduces the network time down to roughly 1ms, which is close to the bare minimum for transferring data within the same AWS region.

One more thing: the AWS SDK and the access token

There’s one more piece to this puzzle: the AWS SDK and the access token. Depending on how credentials are configured, there are several ways an application can authenticate against the AWS API: static IAM access keys (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY), an EC2 Instance Profile, an ECS Task Role, STS, and others. Some of these are simple; others require making one more API call to AWS to obtain a valid access token before the application can even talk to DynamoDB.

So, depending on the configuration, we potentially get at least one more HTTP request to an authentication server, which can cost anywhere from 2 to 10ms, sometimes more. And since we already know these requests are expensive (DNS, TLS, and the rest), we should avoid paying that cost repeatedly. There’s a way to do that: leverage the credential caching the AWS SDK already offers. What it does is simple: obtain the credentials once, keep reusing them until they’re about to expire, then refresh them. One fetch, many reuses, instead of one fetch per request.

The code

Laravel lets you override how any cache store gets built via Cache::extend() which we will use to override the dynamodb driver so every DynamoDbClient it builds reuses (1) a persistent curl connection and (2) a cached credential provider, instead of paying for a fresh connection and a fresh credential fetch on every single request:

&lt;?php 

// app/Providers/AnyServiceProvider.php

public function boot(): void
{
    $persistentHandle = curl_share_init_persistent([
        CURL_LOCK_DATA_DNS,
        CURL_LOCK_DATA_SSL_SESSION,
        CURL_LOCK_DATA_CONNECT,
    ]);

    Cache::extend('dynamodb', function ($app, $config) use ($shareHandle) {
    
        $client = new DynamoDbClient([
            'region' =&gt; $config['region'],
            'version' =&gt; 'latest',
            'endpoint' =&gt; $config['endpoint'] ?? null,
            
            // (1) Persistent connection: reuses DNS/TLS/the socket
            'http' =&gt; ['curl' =&gt; [CURLOPT_SHARE =&gt; $persistentHandle]],
            
            // (2) Cached credentials: reused across requests via the APC store
            'credentials' =&gt; CredentialProvider::defaultProvider([
                'credentials' =&gt; new AwsCredentialsCache(Cache::store('apc')),
            ]),
        ]);

        return Cache::repository(new DynamoDbStore(
            $client,
            $config['table'],
            $config['attributes']['key'] ?? 'key',
            $config['attributes']['value'] ?? 'value',
            $config['attributes']['expiration'] ?? 'expires_at',
            $app['config']['cache.prefix'] ?? '',
        ));
    });
}

The result

So, after applying both techniques (cached credentials and persistent curl handle), we get a significant latency reduction from roughly 20ms down to 2ms: 1ms for the network and 1ms for processing.

Real measurements were taken on an AWS EC2 instance connecting to a DynamoDB table in the same region. This particular test didn’t need a perfectly controlled, “ideal” testing environment, because the magnitude of the improvement is large enough to speak for itself - 100 iterations on each side, reading real data from a populated cache table:

Default configuration Optimized configuration
Median 19.10ms 2.84ms
Average 17.76ms 3.36ms

One important note on that 2ms number: it applies only to a warm connection. The very first request on a given process is still "cold" and pays the default ~20ms; that's exactly why the average above is skewed by that single cold cycle sitting among 99 warm ones.

It's also worth being precise about which "warm" this is about, because there are really three distinct states:

  • Cold - no connection exists yet. Full DNS lookup, TCP handshake, and TLS handshake, plus a fresh credential fetch. This is the ~20ms case.
  • Warm, within the same request - a second (or third, or tenth) DynamoDB call made while handling one single user request. This is already fast; with or without anything from this article, it's just Guzzle's normal in-process connection pooling, and it's been true all along.
  • Warm, across requests - a DynamoDB call made while handling a new user request, reusing a connection and credentials left over from a previous, already-finished user request on the same worker. Without persistent connections, this doesn't exist as a state at all: the connection from the previous request gets thrown away the moment that request finishes, so the next one starts cold regardless of how recently the worker was warm.

That third state is the entire point. Persistent connections don't make a single request faster than Guzzle's pooling already would, they make the next request start warm instead of cold, turning "warm for the duration of one request" into "warm for the lifetime of the process." It might seem like this doesn't matter much, since a request is already warm from its second DynamoDB call onward regardless, but that's exactly the gap: without a persistent connection, every single new user request starts cold again, while with one, the connection stays warm indefinitely, across all of them.

What this saves at scale

A few milliseconds per request doesn't sound like much on its own, but it looks different once you multiply it out.

Without persistent connections, every request pays the cold cost, because that third state (warm across requests) simply doesn't exist. At the measured median of 19.10ms, one thousand requests cost:

1,000 × 19.10ms ≈ 19,100ms ≈ 19.1 seconds

of cumulative request latency. With persistent connections, only the very first request on a worker is cold (pays the same ~19.10ms), the other 999 land at the warm, steady-state median of 2.84ms:

(1 × 19.10ms) + (999 × 2.84ms) ≈ 19.10ms + 2,837.16ms ≈ 2,856ms ≈ 2.86 seconds

That's roughly 16.2 seconds of cumulative latency eliminated per thousand requests, an ~85% reduction. Not wall-clock time for any single user, since real traffic is concurrent, not sequential, but 16 seconds of latency that no longer has to be paid by something, somewhere, for every thousand reads. Scale that to the millions of requests a real application serves, and it adds up fast.

Hope this was useful; happy to answer questions.

reddit.com
u/Prestigious-Type-973 — 8 days ago
▲ 17 r/laravel

What cloud provider are you using for your Laravel apps?

I'm sure most people here are using AWS, but I'm especially interested in those running Laravel outside of AWS, for example: Azure, GCP, Hetzner, DigitalOcean, etc.

How do you handle integrating the standard Laravel ecosystem/tools when your cloud provider doesn't have first-class support? Things like queues (AWS SQS), storage (AWS S3), mail (AWS SES), cache (AWS DynamoDB), etc., that go beyond virtualization.

Would be interested to hear your experience.

reddit.com
u/Prestigious-Type-973 — 20 days ago