fetzi.dev

API Caching done right

6 minutes

Especially for web client applications data caching is an important topic. But as an API developer you want to make sure that every type of client can use your caching mechanism to only download data when it is needed and necessary.

Ok you may say that if the client requests an endpoint it wants to retrieve the requested data.

But consider the following example: you are loading a certain product page in your application (a SPA for example) and simply press the refresh button. Your app needs to fetch the product data twice from the API. The second request is completely redundant because it is very unlikely that the resource data has changed between this two requests.

To overcome this issue a developer wants to introduce some sort of caching mechanism to reduce the load on the API side.

In the following sections we will look at how the HTTP cache specifications can help to reduce the amount of requests and reduce the load on the server side of our application.

ETag HTTP Header

In the HTTP/1.1 specification there is a lot of information about resource caching. API data is normally not predestined for normal resource caching but there is a response header field called ETag that gives the resource document a unique identifier for the current version of the document.

Let’s look at a example response from our API:

HTTP/1.1 200 OK
Content-Length: 512
Content-Type: application/json
ETag: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"

{
    "id": 1,
    "name": "Product 1",
    "description": "Product description"
}

This tag identifier can be used on subsequent requests to the same endpoint to inform the server side that the resource document was already loaded in a certain version.

To tell the server about the already loaded version of the document we need to include the If-None-Match header in the request so the API can check if this version is the latest version available.

If this is not the case we will receive the same response as in our first request but with a new ETag value:


HTTP/1.1 200 OK
Content-Length: 512
Content-Type: application/json
ETag: "4963bd713a7eb1bce458868b0c8472bdc8bc5929a7892a92dd24344aea92093d"

{
    "id": 1,
    "name": "Product 1",
    "description": "A new description of the product"
}

If the content of the resource has not changed the server will send a Not Modified response:

HTTP/1.1 304 Not Modified

This response tells the client that the previous fetched version of the document is still valid and can be used.

As you can see the Not Modified response is much smaller than the full document response above, so we save bandwidth with this approach and we should also remove pressure from our API.

Simple caching approach on the client

For the sake of simplicity we will use a basic Vanilla JS implementation of the client. The resource data is stored in session storage.

async function loadResource(type, id) {
    const cacheKey = `${type}-${id}`;
    const storedData = sessionStorage.getItem(cacheKey);
    let headers = {};

    if (storedData) {
        headers['If-None-Match'] = storedData.etag;
    }

    const response = fetch(`/api/${type}/${$id}`, {
        method: 'GET',
        headers
    });

    if (response.status === 304) {
        return storedData.data;
    }

    const etag = response.headers.get('ETag');
    const data = await response.json();

    if (etag) {
    sessionStorage.setItem(cacheKey, { etag, data });
    }

    return data;
}

// load the product with id 1
loadResource('product', 1)

The function checks if there exists a cache entry for the given parameters and if yes it adds the stored ETag as the If-None-Match header to the fetch call.

If the status code of the response is 304 Not Modified it directly returns the cached data.

Otherwise (this is the naive part without error handling ;-) ) it retrieves the ETag header value, stores the data in the cache and returns the fetched data.

Generating the ETag

The following code examples are taken from a slim application and use the PSR-7: HTTP message interfaces, PSR-15: HTTP Server Request Handlers and PSR-16: Common Interface for Caching Libraries Standards.

Each handler function has access to a $this->cache field that implements PSR-16.

At first lets look at the basic GET handler that adds the ETag header to the response.

<?php

function getProduct(ServerRequestInterface $request, ResponseInterface $response, $args)
{
    $id = $args['id'];
    $product = $this->productRepository->load($id);

    $etag = hash('sha256', sprintf('product-%d-%d', $product->id, $product->updated_at));

    $response = $response->withHeader('ETag', $etag);
    $response->getBody()->write(json_encode($product));
    return $response;
}

As you can see, it is not too much action to add the ETag header to an API response. The only important part is to add an information that changes on every resource update, in our case the updated_at field.

Handling the If-None-Match header

To be able to generate the ETag of a resource, we need to fetch the resource from the database. This is normally the most time consuming and resource intensive part of your request handler.

One of our goals was to also reduce the load on our APIs with this approach (and on our database servers as well). So we need to adapt our plan to achieve this goal.

To be able to do the ETag check fast in our API we need to store each generated ETag in our server side cache. So let’s adapt the code for getProduct:

<?php

function getProduct(ServerRequestInterface $request, ResponseInterface $response, $args)
{
    $id = $args['id'];
    $product = $this->productRepository->load($id);

    $etag = hash('sha256', sprintf('product-%d-%d', $product->id, $product->updated_at));

    $this->cache->set($etag, $id);

    $response = $response->withHeader('ETag', $etag);
    $response->getBody()->write(json_encode($product));
    return $response;
}

We simply store the ETag value as the key and and arbitrary value.

The Client Cache Middleware

To be able to quickly respond to GET requests containing the If-None-Match header we can introduce a simple middleware that should be added as one of the first middlewares into the handler stack.

<?php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;

class ClientCacheMiddleware implements MiddlewareInterface
{
    // ... class fields and constructor

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if ($request->hasHeader('If-None-Match')) {
            $etag = $request->getHeader('If-None-Match');

            if ($this->cache->has($etag) {
                return (new Response)->withStatus(304);
            }
        }

        return $handler->handle($request);
    }
}

Handling resource updates

To be able to inform clients that a resource was updated we need to always delete the current ETag from our cache when the resource gets manipulated.

Otherwise data changes would not be communicated to the client(s).

It will also be a good idea to provide an endpoint to delete all stored ETag values from the cache for a given resource type. To do so you can make a smart update to your ETag format. You simply move the resource type to the prefix of the calculated hash. Let’s look at an example:

ETag: "product/4963bd713a7eb1bce458868b0c8472bdc8bc5929a7892a92dd24344aea92093d"

With this format change we can lookup all keys in the cache starting with product/. With a redis cache this can be done with the KEYS product/* command, that retrieves all available cache keys for this wildcard search. Now we can delete each cache entry and all previously issued ETags for the product resource get invalidated.

Conclusion

This approach is an universal way to allow the consumers of our API to cache the fetched resources until the resource data changes. It also reduces the pressure on our API servers, the database and also the cache because the cache entry for a resource is rather small.

It is also an opt-in approach for your clients. The implementation effort and also the time consumtion per request is negligible on the API side and it is up to the client to use the information provided by the server.

Do you enjoy reading my blog?

sponsor me at ko-fi