← Back to projects

Architecture deep dive

From a Sanity edit
to a live page.

A walk through how a change in a content editing tool ends up on a ferry booking website, what each step does, and where the time goes. I'll explain the technical words as we hit them, and there's a short glossary at the bottom.

The short version

~13 sEstimated time from an edit in Sanity to the data being ready for the website. I added up the typical (median) time of each step.

The site I work on runs ferry booking pages, and a lot of what's on them (port descriptions, FAQs, image galleries) is written and edited in Sanity, a content management system (CMS). A CMS is the tool editors use to change website content without touching code.

My job was the bridge: when someone creates, updates or deletes a document in Sanity, the right parts of the website need to find out and update themselves. I architected this solution within the company's existing event-driven platform, from the pipeline that takes content changes in to the tables that serve the pages.

Event-driven means systems don't ask each other "anything new?" over and over. Something happens, it gets announced as an event, and anything that cares listens for it. The platform is built that way for low latency (fast), high granularity (small, separate pieces) and high scalability (it copes with growth). I based the design on the principles and patterns in John Gilbert's book Software Architecture Patterns for Serverless Systems, and designed the DynamoDB tables using the strategies in Alex DeBrie's The DynamoDB Book.

Following one edit

Four steps, start to finish

Here is one change, from the moment someone hits publish. The first three steps run when content changes. The last one runs whenever a visitor loads a page.

Quick vocabulary: a webhook is a message one system automatically sends to another when something happens. A Lambda is a small piece of code AWS runs for you only when it's needed. DynamoDB is AWS's database for fast lookups by key.

Receive and store the change

SanityCallbackEvent storeWebhookStore event

Sanity sends create, update and delete webhooks to an API Gateway REST endpoint. The ESG callback Lambda stores received webhook events in DynamoDB for replayability.

Enrich and publish

DDB streamTrigger LambdaSanityStored changeFetch documentFull document↓ Publish to EventBridge → Kinesis

The DynamoDB stream invokes a separate trigger Lambda, which fetches the full document from Sanity and publishes domain events to EventBridge, then Kinesis.

Update each read model

KinesisBFF LambdaDynamoDBSubscribed eventRelevant data

Each BFF listener filters for the events it needs and keeps the relevant data in its own DynamoDB store.

Serve the page

Qwik MFEBFF APIDynamoDBFetch dataRead recordsRecordsPage data

The BFF API is exposed through API Gateway. Qwik renders on AWS Lambda, with client JavaScript where needed.

Why store the webhook before doing anything with it?

The first thing that happens to a webhook is that it's saved in a DynamoDB table, before it's turned into anything else. The main reason is auditing and debugging: because every webhook we received is kept, we can look back at exactly what Sanity told us. We can also feed them through the pipeline again, but a replay isn't guaranteed to give the same result as the first time, because a later step asks Sanity for the document again.

The receiving service is an external service gateway (ESG). That's a pattern where one service sits at the edge, talks to the outside system (here Sanity) and translates for everything behind it. Storing the webhooks was my design decision.

Why does a second step fetch the document again?

The stored webhook is only a note that something changed: the document's ID, its type, whether it was created, updated or deleted, and when. Our content is modelled as lots of linked documents. For example, the Dover to Calais route document refers to the Dover and Calais port documents. So a separate Lambda picks the note up from the DynamoDB stream (a feed of changes to the table) and asks Sanity for the document, with one query per document type. For a route, that query follows the links to both ports. It brings back each port's name, its UN/LOCODE (the standard code for a port) and its country. It builds the route's name from the two port names, and it lists the IDs and types of the documents the route points at.

Sanity can attach some linked data to a webhook itself, so this was a choice rather than a workaround. With the queries in our own code, we version and test them like everything else. Every webhook stays the same small shape whatever the document type. And the Lambda reads the document straight from Sanity's published content at the moment it publishes the event.

It then publishes events to EventBridge, AWS's event router, and from there onto Kinesis, AWS's streaming service, where other systems can read them. On the BFF side, those references are used to tie the data together. Without them, a route page couldn't show any specific port information for its two ports.

Why does each website part keep its own copy of the data?

The website is split into micro-frontends (MFEs): small, separately deployed pieces, each responsible for one part of the site. Each MFE has its own backend-for-frontend (BFF), a small backend that exists only to serve that one MFE. A BFF listens to the Kinesis stream and keeps only what its MFE needs.

A concrete example: the port page MFE doesn't care about an update to a root page or a root document, so its BFF filters those events out and ignores them. It only stores the data that matters for ports.

Why is serving a page fast?

Because page requests never go back to Sanity. The page asks its BFF's API (through API Gateway, AWS's front door for APIs), and the BFF reads from its own DynamoDB table. The table is designed around exactly the lookups the page needs, which makes those reads very quick. The pages themselves are built with Qwik, a frontend framework, and rendered on the server in a Lambda wherever possible. Anything that has to run in the browser is sent as JavaScript.

Read models for each frontend

Each backend-for-frontend subscribes to relevant events and keeps its own data for the micro-frontend it serves.

KinesisContent events
Port description
Heading
Breadcrumb
Ports list
Port search
Image gallery
FAQ
Sitemap

Each BFF builds its own view from the shared event stream.

Read through the BFF API

Qwik MFE requests data from the BFF API. The API queries DynamoDB, receives records and returns data to the MFE.

When things get big

Documents that are too large for an event

Some Sanity documents contain a lot of rich text, and that can make an event too big to publish, especially when it carries every language version. Trying to publish it would simply fail. So the gateway checks the size first. If a payload would be too big, it uploads the whole thing to S3 (AWS's file storage) and publishes events that carry the file's location instead of the content. Consumers download the file from S3 and ingest it much as they would an ordinary event.

It's one extra moving part, added so that large documents still make it through.

Watch a too-big event take the S3 route

First, what would happen if the gateway just tried to publish a big document. Then what it actually does.

The gateway saves the full content to S3 and publishes a small event with its location. The listener fetches the content from S3.
Gateway uploads full payload to S3Event carries the S3 locationConsumer downloads the payload and ingests it

We added this route after an event hit the size limit in production. I didn't expect to hit it and didn't know about it until it happened. The events were large mostly because they carried content for every locale, meaning every language version of a document. Most events are well under the limit, but the ones with the most content are the ones that matter most.

AWS has since raised its limit for events, messages and asynchronous Lambda invocations from 256 KB to 1 MB, as described in an AWS Compute Blog post from January 2026. Some events still need the S3 route today, even with the higher limit.

Where the time goes

About 13 seconds, and most of it is waiting

The code in this pipeline is quick. Added together, the actual compute is about 130 milliseconds. The rest of the ~13 seconds is waiting between steps. These are the typical (median) times for an ordinary hour:

The first step is measured on real arrival times: across about 16,700 webhooks, 98.6% reached AWS within 5 seconds.

The big bar is the wait before each listener picks the event up off the Kinesis stream. That comes from more than 50 BFFs reading from one stream and sharing its read capacity. The stream has a single shard, and Kinesis allows five reads per second on a shard, shared between every reader. With over 50 readers taking turns, each one gets to read about once every ten seconds, which is close to the 9.4 second median.

What I'd change next

Giving each backend its own way to read

The 9-second wait comes from how many backends read the same stream. More than 50 BFFs (the small backends, one per part of the website) read from one Kinesis stream, and by default they all share the same read capacity, so they end up waiting on each other. The improvement I'd make is to give each BFF its own dedicated way to read, which I expect would remove most of that wait. The BFFs on this stream don't use enhanced fan-out today. Each BFF has a listener Lambda with the stream as its event source (the setting that tells Lambda to run the code whenever new records arrive), a DynamoDB table to store what it reads, and a separate REST Lambda behind API Gateway that serves requests.

A best-case estimate of the saving. AWS documents a typical average delivery delay of about 70 milliseconds for a reader with its own dedicated connection, however many other readers there are. For comparison, it documents about 200 milliseconds with one shared reader and about 1,000 milliseconds with five. If the whole 9.4 second wait is the cost of sharing, and dedicated reading brought it down to about 70 milliseconds, that would save about 9.3 seconds and bring the roughly 13 second total to roughly 3.7 seconds. That's the best case, but the arithmetic above suggests sharing really is most of the wait.

How it would work. Kinesis splits a stream into shards, and every reader reads every shard, so adding shards alone doesn't give each BFF its own lane. The AWS option built for this is called enhanced fan-out. Each registered reader gets its own read capacity, and AWS pushes new records to it instead of the reader repeatedly asking for them. AWS limits how many readers can be registered this way: 20 per stream on standard streams, and 50 on streams in its on-demand Advantage mode. Because each BFF reads through a Lambda event source, AWS lets that event source be pointed at a dedicated reader, which is a configuration change rather than new application code. With more than 50 BFFs, that limit means not every BFF can have a dedicated reader on one stream. One way round it is to give dedicated readers only to the BFFs where freshness matters most. Another is to have EventBridge send events to a few streams instead of one, so fewer BFFs share each. Splitting the readers across three streams would cut the wait to about a third with no change to any BFF's code.

Why it wasn't done first. The extra setup and cost weren't judged worth it. Before this system, content was compiled once a night by a scheduled job, and it had badly stagnated as a result. Going from a day or more to about 13 seconds was already a huge improvement. Enhanced fan-out is billed per reader per shard-hour, plus for the data read.

When things go wrong

Failures, repeats, deletes and the road not taken

What happens when a step fails?

When a step fails, its Lambda publishes a fault event, a message announcing that something went wrong. Fault events are collected and monitored. With this many separate systems, seeing what is happening across all of them is hard, and that is an ongoing challenge.

What if a change arrives twice, or out of order?

Each document keeps its last-updated time in the table. A write from an event is conditional: it only goes through if the event's updated time is later than the one already stored. The check and the content changes are written together in one DynamoDB transaction, so if the check fails, nothing is written. An older or repeated change can't overwrite newer data.

What about deletes?

Sanity sends delete events too. Each read model listens for them and clears the matching data out of DynamoDB. The same clean-up runs on updates: if a document stops pointing at another one, the stored link is removed.

Why not read from Sanity directly?

The alternative was to use Sanity's own API as the backend for the website. We didn't want an outside service to be a dependency for all of our content. We were also concerned about latency, and about having little control over caching.

Who built it, and when?

I did most of the architecture on my own to establish the pattern. Colleagues then picked it up as we replicated the services. It went live in June 2025.

Wrapping up

What this shows

In short: Sanity tells us something changed, we store that message, ask Sanity again for what links to what, announce it as an event, and each part of the website keeps the slice it needs in its own database. Pages read from that database, and the stored webhooks give us a record to audit and debug from.

The code takes about 130 milliseconds of the 13 seconds. The rest is waiting, mostly for a turn to read the stream, so the next gain comes from changing how the backends share the stream, not from faster code.

Glossary

Words used on this page

API Gateway
AWS's front door for APIs. Requests arrive here and get passed to code behind it.
Backend for frontend (BFF)
A small backend built to serve one specific part of the website, and nothing else.
CMS
Content management system. The tool editors use to change website content.
DynamoDB
AWS's database. Very quick when you look things up by a key you've planned for.
EventBridge
AWS's event router. Systems publish events to it and rules send them where they need to go.
Event-driven
Systems announce what happened and others listen, instead of asking repeatedly.
External service gateway (ESG)
A service at the edge that talks to an outside system and translates for everything behind it.
Kinesis
AWS's streaming service. Events are written to it and many listeners read them in order.
Lambda
A small piece of code AWS runs on demand, with no server to manage.
Median
The middle value. Half of the measurements were faster, half were slower.
Micro-frontend (MFE)
A small, separately deployed piece of a website, each owning one area.
Qwik
A frontend framework that does as much work as possible on the server.
Fault event
A message published when a step fails, so the failure can be collected and monitored.
Replay
Running stored events through the pipeline again.
S3
AWS's file storage.
Sanity
The CMS our editors use.
Webhook
An automatic message one system sends to another when something happens.

The next deep dive, in your inbox.