In standalone mode, Next.js prebuilds the ISR cache during the build process. And at runtime, NextServer expects this cache locally on the server. This works effectively when the server is run on a single web server machine, sharing the cache across all requests. In a Lambda environment, the cache needs to be housed centrally in a location accessible by all server Lambda function instances. S3 serves as this central location.
To facilitate this:
- ISR cache files are excluded from the
server-function
bundle and instead are uploaded to the cache bucket. - The default cache handler is replaced with a custom cache handler by configuring the
incrementalCacheHandlerPath
(opens in a new tab) field innext.config.js
. - The custom cache handler manages the cache files on S3, handling both reading and writing operations.
- Since we're using FIFO queue, if we want to process more than one revalidation at a time, we need to have separate Message Group IDs. We generate a Message Group ID for each revalidation request based on the route path. This ensures that revalidation requests for the same route are processed only once. You can use
MAX_REVALIDATE_CONCURRENCY
environment variable to control the number of revalidation requests processed at a time. By default, it is set to 10. - The
revalidation-function
polls the message from the queue and makes aHEAD
request to the route with thex-prerender-revalidate
header. - The
server-function
receives theHEAD
request and revalidates the cache. - Tags are handled differently in a dynamodb table. We use a separate table to store the tags for each route. The custom cache handler will update the tags in the table when it updates the cache.
Lifetime of an ISR request for a stale page
- Cloudfront receives a request for a page. Let's assume the page is stale in Cloudfront.
- Cloudfront forwards the request to the
server-function
in the background but still returns the cached version. - The
server-function
checks in the S3 cache. If the page is stale, it sends the stale response back to Cloudfront while sending a message to the revalidation queue to trigger background revalidation. It will also change the cache-control header tos-maxage=2, stale-while-revalidate=2592000
- A new request comes in for the same page after 2 seconds. Cloudfront sends the cached version back to the user and forwards the request to the
server-function
. - If the revalidation is done, the
server-function
will update the cache and send the updated response back to Cloudfront. Subsequent request will then get the updated version. Otherwise, we go back to step 3.
Tags
Tags are stored in a dynamodb table.
There is 3 fields in the table: tag
, path
, revalidatedAt
. The tag
field is the partition key and path
is the sort key.
We use an index called revalidate
with path
as a partition key and revalidatedAt
as the sort key.
Each tags has several paths, and every subpath is also considered as a tag. For example, if we have a tag tag1
with path /a/b/c
, we also have tags /a
, /a/layout
, /a/page
, /a/b
, /a/b/layout
, /a/b/page
, /a/b/c/layout
, /a/b/c/page
.
When revalidateTag
is called, we update the revalidatedAt
value for each path and subpath associated with this tag.
When we check if a page is stale, we check the revalidatedAt
value for each record and the LastModified
of this S3 cache objects . If revalidatedAt
is greater than LastModified
, we consider the page is stale.
Cost
Be aware that fetch cache is using S3. fetch
by default in next is cached, and even for SSR requests, it will be written to S3. This can lead to a lot of S3 requests and can be expensive. You can disable fetch cache by setting cache
to no-store
in the fetch
options. Also see this workaround
get
will be called on every request to ISR and SSG that are not cached in Cloudfront, and set
will be called on every revalidation.
They can also be called on fetch requests if the cache
option is not set to no-store
.
There is also some cost associated to deployment since you need to upload the cache to S3 and upload the tags to DynamoDB.
For the examples here, let's assume an app route with a 5 minute revalidation delay in us-east-1. This is assuming you get constant traffic to the route (If you get no traffic, you will only pay for the storage cost).
S3
- Each
get
request to the cache will result in at least 1GetObject
GetObject cost - 8,640 requests * $0.0004 per 1,000 requests = $0.003456
Total cost - $0.003456 per route per month
- Each
set
request to the cache will result in 1PutObject
in S3
PutObject cost - 8,640 requests * $0.005 per 1,000 requests = $0.0432
Total cost - $0.0432 per route per month
You can then calculate the cost based on your usage and the S3 pricing (opens in a new tab)
DynamoDB
For the example, let's consider that that same route has 2 tags and 10 paths and subpath for each tag. This is assuming you get constant traffic to the route.
- Each
revalidateTag
request will result in 1Query
in DynamoDB and aPutItem
for each path associated with the tag, they are grouped in batches of 25 in aBatchWriteItem
request.
Assuming you do 1 revalidation per 5 minute
Query cost - 8,640 request * $0.25 per 1,000,000 read = $0.00216
BatchWriteItem cost - 86,400 requests * $0.25 per 1,000,000 write = $0.0216
Total cost - $0.04536 per tag revalidation per month
- Each
get
request will result in 1Query
in DynamoDB
Query cost - 8,640 request * $0.25 per 1,000,000 read = $0.00216
Total cost - $0.00216 per route per month
- Each
set
request will result in 1Query
in DynamoDB and aPutItem
for each tag associated with the path that are not present in DynamoDB, they are grouped in batches of 25 in aBatchWriteItem
request.
Query cost - 8,640 request * $0.25 per 1,000,000 read = $0.00216
Total cost - $0.00216 per route per month
You can then calculate the cost based on your usage and the DynamoDB pricing (opens in a new tab)