Skip to content

Part 7 of 7 · Page speed watcher series ~7 min read

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

The page speed watcher drawn with AWS service namesThree boxes across the top outside the AWS account. Your pages, loaded for real by a headless browser. The Deploy feed, arriving as a webhook or an S3 drop. And SES outbound, carrying regression messages and the monthly summary. Inside the account, three groups. EventBridge providing a daily trigger and SQS carrying one message per page. Three Lambda functions named measure, compare and report. And two DynamoDB tables named runs and budgets. A note gives the region as us-east-1, one account, and notes that measure is a container image Lambda because a browser does not fit in a zip package.AWS ACCOUNTYour pagesloaded for realDeploy feedwebhook or S3 dropSES outboundregressions, monthlyEventBridge + SQSdaily trigger,one page per messageLambda x3measure, compare,reportDynamoDB x2runs, budgetsingroundsoutus-east-1. One account. A container image Lambda, because a browser does not fit a zip.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
ps-measureSQS page queueNine loads of one page on the fixed profile; stores medians and resources300s / 3008 MB, container image
ps-compareSQS measured queueBaseline, spread, two-run confirmation, budget check30s / 512 MB
ps-reportSQS regression queue + EventBridge monthlyResource diff, deploy correlation, the monthly summary60s / 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

RoleAllowedOn
ps-measure-roledynamodb:PutItem, s3:PutObjectThe runs table; the traces prefix
ps-compare-roledynamodb:Query/UpdateItem, sqs:SendMessageRuns and budgets; the regression queue
ps-report-roledynamodb:Query, ses:SendEmailRuns, 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