A apuração do 2º turno para presidente ainda não começou: Flávio Bolsonaro e Lula estão com 0% e 0 votos. Os números aparecem quando o TSE publicar o primeiro boletim do 2º turno. Dados do TSE às 09:38:12 de 15/10/2026.
A technical demo
This site is an EadTech demo, built in a partner sandbox. Its purpose is to put AEM Edge Functions, a feature released in beta, to a real-world test alongside Edge Delivery Services, Universal Editor and App Builder. It has no commercial purpose, it is not a commercial product, and it is not a production service.
We picked a demanding case on purpose: a scoreboard that changes every minute, read on phones, with a statistical projection and without exposing the data source.
Who does what
Two pieces do the work, each on its own shift. The Edge Function runs at the edge on every visit and assembles the page in a few milliseconds, close to the reader. App Builder works behind the scenes: one action wakes up every minute to read the TSE files and run the numbers, and two others answer only the edge. No visit ever reaches the TSE or App Builder directly.
Hover over a piece (or give it keyboard focus) to see what it talks to.
Assembles every page: it fetches the HTML from Edge Delivery and the panel from App Builder at the same time, puts the numbers into the page and returns it ready to render, with the security headers.
It hides the origin addresses. It also serves /api/painel.json, /llms.txt and the consent routes.
The path of a request
Why an Edge Function
- The page arrives with the scoreboard in it. Without the edge, the browser would open an empty page and then go fetch the numbers. With it, the first frame already shows the result, which improves the Lighthouse score and makes the content readable for search engines and AI agents.
- One domain, nothing exposed. App Builder and the TSE sit behind the function. The secret that unlocks App Builder is added by the CDN and never reaches the browser or the code.
- Less load on the origin. The edge answers with a panel cached for 10 seconds and refreshes it only once. App Builder gets a handful of calls per minute, no matter how many people are reading.
- Consent logging on our own domain. Cookie choices are recorded through the edge, with a receipt, and the report opens only with an authorized account.
- Live text for agents. /llms.txt is generated at the edge with the current state of the count.
We considered two alternatives: calling App Builder straight from the browser (which exposes the address and opens the action to anyone) and a static page with JavaScript fetching the data (the page opens empty and performance suffers).
Under the hood, in detail
This part is for readers who will open the code or reproduce the setup in another project. Timings were measured on the live domain on October 21.
1. The request, step by step
- CDN rules. The request reaches the AEM managed CDN. Traffic filters run first: so-leitura blocks any method other than GET, HEAD or OPTIONS (except on /consent-log), and painel-sem-enxurrada limits each IP to 30 requests per 10 seconds on the /api/* and /consent-* routes, with a 60-second penalty.
- The secret is added to the request. A request transformation sets the x-require-whisk-auth header to the value of the pipeline's secret variable APURACAO_ORIGEM_SEGREDO. The browser never sees that value, and it isn't in the code.
- Origin selection. An origin selector routes to the edgefunction-apuracao-borda origin the pages (paths with no extension), /llms.txt, /api/painel.json, /api/serie.json and the three consent routes, the latter uncached. Scripts, styles, fonts, images and spreadsheets go straight to Edge Delivery with normal caching.
- The Edge Function fetches both origins in parallel. Using Promise.all, it requests the HTML from Edge Delivery (60 s edge cache) and the panel from App Builder (10 s cache), passing the secret along. Measured uncached: 100 to 320 ms for both fetches, and 1 to 2 ms to assemble the page.
- Assembly. The function injects into the <head> an island <script type="application/json" id="painel-inicial"> holding the panel, escaped so it can't close the tag or open an HTML comment. At the top of <main> it adds a plain-text summary for clients that don't run JavaScript (search engines and AI agents). The page's JavaScript removes that summary during the eager phase.
- Response. It goes out with cache-control: public, max-age=0, s-maxage=10, stale-while-revalidate=30: the CDN keeps it for 10 s and may serve the previous copy for another 30 s while it refreshes. Requests arriving within those seconds are served with a single fetch to the origin. The server-timing header carries both timings to the browser, and the top bar displays them.
2. Inside the Edge Function
- JavaScript on Fastly Compute (@fastly/js-compute), compiled to WebAssembly. One entry file, borda/src/index.js, with assembly in montar.js, which is pure and tested in Node.
- Configuration comes from ConfigStore('config_default'), which is shared by the whole program. That's why this site's keys carry the APURACAO_ prefix, and the code has a default for each one, since the sandbox doesn't create the config store or a secret store for Edge Functions.
- Closed list of destinations: the function only calls fetch on the Edge Delivery host and the Runtime host. Any other destination is rejected before the request leaves. This stands in for the origins field, which Cloud Manager doesn't accept for an Edge Delivery site.
- Per-fetch caching with CacheOverride('override', { ttl }): 60 s for pages, 300 s for llms.txt, 10 s for the panel.
- Edge Delivery sometimes returns gzipped text even when the client didn't ask for compression. The fetch uses fastly: { decompressGzip: true }, and if the body is still compressed, the function decompresses it with DecompressionStream. Without this, /llms.txt returned 502.
- The security headers (HSTS with preload, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP and CORP) are set by the function itself. The response transformations in cdn.yaml don't apply to what an Edge Function returns. The function also strips headers that would reveal what sits behind it (x-openwhisk-activation-id, x-powered-by, x-gw-cache) and the x-robots-tag: noindex that aem.live adds to its own responses.
- Behind the CDN, the public host arrives in x-forwarded-host, not in host. The consent route uses it to check which site a record came from.
- Any exception becomes a 502 with no-store and a short message, still with the security headers, and the error goes to the function log.
- Deployment: the Cloud Manager config pipeline declares the function (edgeFunctions.yaml). Then aio aem edge-functions deploy uploads the package using an OAuth Server-to-Server credential with the AEM CDN API and the aem.cdn scope. The deployment API lives on the site's own domain, under a reserved path, so the read-only rule makes an exception for that path.
3. Inside App Builder
- coletar: Node.js 22, 512 MB, 58 s limit, not exposed to the web. A trigger from the Runtime alarms package (/whisk.system/alarms/alarm) fires it every minute. Each cycle reads the 29 totals files (26 states, the Federal District, abroad and Brazil), all with If-None-Match: anything unchanged comes back as 304 and costs the TSE almost nothing. Then it reads up to 600 municipality files, only those not yet complete, within a 40 s budget. On a 429 or 5xx error it retries with increasing backoff, and if 429s keep coming, it lowers the number of municipalities in the following cycles. Finally it runs the model and saves the snapshot.
- App Builder State: the keys meta (ETag and summary for each file), m-UF (each state's municipalities), painel (the JSON the page uses) and serie (up to 600 points across the night, for the chart that follows the count). Consent records live in r-<recibo> for one year.
- painel: a web action with final and require-whisk-auth. Without the right secret, Runtime itself rejects the request before running any code. It responds with cache-control: private, no-store so the Runtime gateway keeps no copy: a cached copy would be served to anyone calling Runtime directly. Caching happens at the edge.
- consentimento: also locked by the secret. Three operations: register (each choice, with a receipt), receipt (a visitor checks their own) and export (the report). To export, the /consentimentos page signs in with a corporate account (IMS) through the IMS OAuth flow with PKCE for single-page apps, keeps the token only in the browser session and sends it as a Bearer. The edge forwards the token in a dedicated header, and the action checks three things: that the token was issued for our client ID, that IMS still accepts it (the profile endpoint responds) and that the email is on the authorized list. In the log, the email appears only as the prefix of a SHA-256 hash.
- Deployment uses the Runtime REST API (appbuilder/publicar.mjs), with the secret and the authorized list coming from .env, which stays out of the repository. After the count, node publicar.mjs --parar turns the alarm off.
4. The projection model
- The unit is the municipality: 5,757 of them, abroad included. Each one's swing is the logit of Flávio's two-way share in the runoff minus the logit of his share in the first round.
- Transfer coefficients (the other candidates' first-round share and regression to the mean) are estimated within each state, with ridge regularization. The hierarchy is country, region and state, with a national prior from the polls.
- Municipalities are weighted by votes × f⁴, where f is the fraction already counted. A big city's partial count is misleading, because the zones that report first aren't like the ones that report later, so the swing comes mostly from places that have finished.
- Uncertainty comes from a Monte Carlo run with 3,000 simulations (region, state, uncounted portion of each state, municipality, and the ratio of turnout to valid votes). The win probability only appears once 15% of polling stations are counted and ballots have come in from at least 20 states.
- Backtest on the 2022 runoff, with zones entering in the actual order the TSE closed them: an error of about 0.1 point from 15% counted onward, and the 90% interval contained the result at every point measured.
5. The page in the browser
- Edge Delivery Services with authoring in Universal Editor (xwalk model). Each block has its own content model; interface copy comes from a labels spreadsheet, and the polls from five spreadsheets published as JSON.
- Three-phase loading: the eager phase loads only what the largest element on screen needs, the scoreboard number; everything else comes in the lazy and delayed phases. The font used for the numbers is preloaded, and fallback fonts have adjusted metrics so the swap doesn't shift the layout.
- The scoreboard reads the painel-inicial island before going to the network, so the first frame already has the numbers and there's no layout shift. After that it checks /api/painel.json every 15 s while the tab is visible and every 60 s while it's hidden, and redraws only what changed.
- Every area that changes has reserved height. The top bar has a fixed height for the same reason.
- CSP with nonce and strict-dynamic declared in head.html; Edge Delivery moves it to the response header and rotates the nonce on every response.
- Publishing in Universal Editor triggers push invalidation on the managed CDN: new content shows up on the domain in about 10 seconds. Code goes from GitHub to Edge Delivery through Code Sync.
- A GitHub workflow runs Lighthouse against the published domain, three times per profile (mobile and desktop), every hour and every 15 minutes on election night, and saves the result the top bar reads.
6. What the sandbox changes
- No secret store or KV for Edge Functions: the secret comes in through the CDN as a header, and records live in App Builder State.
- There is a single CDN configuration for all Edge Delivery sites in the program. This site's rules sit in the same file as the others, each scoped by domain, and will be removed with a git revert after the count.
How the projection is calculated
Each municipality is compared with its own first-round result, counting only the two candidates. The swing measured in municipalities already counted estimates what remains in each state, using a country, region and state hierarchy. Municipalities carry more weight the more complete their count is, because a big city's partial count is misleading. Uncertainty comes from 3,000 simulations. The win probability only appears once 15% of polling stations are counted and ballots have come in from at least 20 states.
In a backtest on the 2022 runoff, using the actual order in which ballots arrived, the projection's mean error was 0.04 percentage point.
Performance, accessibility and security
- Lighthouse is measured by the GitHub workflow on the published site, using the mobile profile. The top bar shows the latest measurement and also what your own browser measured on this visit.
- A+ rating on securityheaders.com: CSP with nonce, HSTS, cross-origin isolation (COOP, COEP, CORP) and traffic filters at the CDN.
- Accessibility profile with contrast, text size, reduced motion and keyboard shortcuts.
- New browser features, tested carefully: View Transitions, Popover with CSS Anchor Positioning, container queries and WebMCP. Browsers without a feature show the same numbers, just without the effect.
What we learned from Edge Functions
- In a program with several Edge Delivery sites, Cloud Manager applies a single CDN configuration to all of them. Each site's rules have to live in the same file, separated by domain.
- Headers defined in cdn.yaml don't apply to what the Edge Function returns. The function has to set them itself.
- The function's deploy API goes through the site's domain. A traffic filter that blocks writes must leave that reserved path open.
- Edge Delivery serves some text already compressed. The function has to decompress it before touching the body.
- The sandbox offers no config, secrets or KV for the function: values live as defaults in the code, and the secret comes in through the CDN.
Disclaimer
The official numbers are those of Brazil's Superior Electoral Court (TSE). This site reads the TSE's public files and may lag, fail or show incomplete data. The projection and the win probability are statistical estimates, not results. The site has no affiliation with the TSE, parties, candidates or campaigns.
It is an EadTech technical demo running in the partner sandbox, used under the program's terms: for demonstration and training only, with no production use, no pilot, no load testing and no storage of personal data. AEM Edge Functions is a new feature, and parts of the site may stop working at any time. It is not a product or a commercial service.
Sources: results and candidate photos come from public TSE databases (results files and DivulgaCandContas). The polls cited are those registered with the TSE.