Engineering reference: the page speed watcher architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the browser packaging, and why there is no model in it.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- App integration
- Front-end & mobile
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
ps-measure | SQS page queue | Nine loads of one page on the fixed profile; stores medians and resources | 300s / 3008 MB, container image |
ps-compare | SQS measured queue | Baseline, spread, two-run confirmation, budget check | 30s / 512 MB |
ps-report | SQS regression queue + EventBridge monthly | Resource diff, deploy correlation, the monthly summary | 60s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
ps-measure-role | dynamodb:PutItem, s3:PutObject | The runs table; the traces prefix |
ps-compare-role | dynamodb:Query/UpdateItem, sqs:SendMessage | Runs and budgets; the regression queue |
ps-report-role | dynamodb:Query, ses:SendEmail | Runs, read; one verified identity |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: runs
PK page S /pricing
SK run_at S 2026-07-29T06:00:00Z
lcp_median N 2.04 — seconds
lcp_samples L all nine, so a run can be judged later
cls_median N 0.03
tbt_median N 180 — milliseconds
bytes_total N 742000
bytes_js N 164000
bytes_third_party N 118000
requests N 41
resources L [{url, type, bytes, ms}] — the diff source
browser_version S recorded so a step change is explainable
profile_id S v1 — bumped only when the profile changes
`profile_id` is what makes a methodology change visible. Comparisons never
cross a profile boundary; the chart shows a break instead of a smooth lie.
Table: budgets
PK page S /pricing
bytes_total N 800000
bytes_js N 180000
bytes_images N 400000
bytes_third_party N 150000
requests N 45
set_at S 2026-04-02
set_from S current+10% | intention
breached_runs_q N how many runs this quarter were over
`breached_runs_q` over half the quarter’s runs triggers the quarterly
question: is the budget wrong, or is this a real problem nobody has done?
Inbound and outbound
- The measure function is a container image Lambda at 3008 MB. A headless browser does not fit in a zip package, and the memory setting is really a CPU setting — a browser on 512 MB measures a slower page than the same page on 3008.
- One page per SQS message, so nine samples of one page fit comfortably inside a single invocation and a failure on one page does not lose the run.
- The deploy feed is a webhook writing to S3, or an S3 drop from CI. It needs only a timestamp and an identifier; the correlation is a time comparison.
- Nothing is fetched from a third-party performance API. The measurements are your own, which is what makes the history comparable.
The model call
- There is no model in this system. Medians, spreads and resource diffs are arithmetic.
- The tempting use is generating a recommendation from a resource diff, and it is worse than the diff: “hero-q3.jpg is 1.4MB” is already the recommendation.
- The report wording is fixed, with the numbers substituted, which also means it says the same thing every time and gets read faster.
- Attribution is a comparison between two resource lists, not a judgement.
- The cost page assumes none, which is why the whole variable cost is browser time.
Things worth knowing before you build it
- Pin the browser version and record it. An auto-update produces a step change on a day nobody deployed, which is exactly how a monitor loses credibility.
- Bump profile_id when the profile changes, and never compare across it. A methodology change drawn as a smooth line is worse than no chart.
- Set Lambda memory high. On a browser workload memory is CPU, and a low setting measures your own throttling rather than the page.
- Close the browser in a finally block. A leaked browser costs the full function timeout on every invocation.
- Keep the page list short. Cost is linear in pages times samples, and a report covering eighty pages is one nobody reads anyway.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts