The Two-Second Illusion: Making Council Pages Feel Quick on a Wobbly Connection
Photo: Rawpixel.com, CC0, via Wikimedia Commons
Council websites have a thankless job. On the same Tuesday afternoon, one visitor is booking a bulky waste collection from a stairwell with two bars of signal, another is renewing a licence on a work laptop that predates the pandemic, and a third is running a screen reader against a page that has just re-rendered for the fourth time. All three of them are judging you by the same measure: does this feel like it's working?
Most councils answer that question with a spinner. It is the cheapest possible answer and one of the least useful.
A spinner is an admission, not an update
Think about what a spinning circle actually communicates: something is happening, we're not telling you what, and we have no idea when it will stop. That's not feedback. That's a shrug rendered in CSS.
There's a well-worn bit of research behind this — people consistently rate a wait as shorter when they can see progress moving than when they can't, even when the clock says otherwise. The load-bearing part isn't the animation, it's the information. Three seconds behind a spinner feels longer than five seconds behind a labelled progress bar, because only one of those gives you a reason to stay.
The irony is that most of the slow moments on a council site aren't slow at all. A planning application lookup that takes 900ms behind a blank white panel will be described as broken. The same lookup at 900ms, with the result table's outline already on screen and the heading already in place, gets described as fast. Nothing changed in the back end. Everything changed in the perception.
Skeletons work — in narrow, specific circumstances
Skeleton screens have become the default answer to this problem, and like most defaults they get misapplied.
They earn their keep when the shape of what's coming is genuinely predictable: a list of library opening hours, a table of planning applications, a grid of school term dates. The visitor sees the container before the contents, and when the contents land there's no jolt.
They backfire badly in three situations:
- When the skeleton doesn't match the final layout. You've now bought yourself a layout shift and a small sense of betrayal. If your skeleton is a 3×2 card grid and the API sometimes returns one row, don't draw six cards.
- When the thing being loaded is a form. A greyed-out ghost of a form is not a form. It's a promise you might not keep, and it invites people to tap things that aren't there yet.
- When the wait exceeds roughly ten seconds. Past that point a skeleton stops being anticipation and starts being mockery. Switch to a progress indicator with a time estimate, or better, move the work off the request entirely and email the result.
Two housekeeping notes. Mark skeletons aria-hidden="true" — they're decorative, and screen reader users shouldn't hear a list of empty divs. And keep the contrast low but not invisible; a skeleton at 1.1:1 against the background just reads as a rendering bug.
Tell people where they are, not just that they're waiting
Some of the highest-anxiety journeys on a council site involve a wait with no visible progress: submitting a form, generating a permit PDF, uploading evidence for a housing case. A spinner here is genuinely unhelpful, because the resident has no idea whether their submission survived.
Name the stages. "Checking your details" becomes "Reserving your slot" becomes "Sending your confirmation". You're not inventing progress you don't have — you're reporting the steps your backend is already taking, which your developers almost certainly know about and nobody has written down.
For anything over a few seconds, set an honest expectation: this usually takes about twenty seconds. British users in particular will wait patiently if you've told them what to expect, and will phone the contact centre if you haven't.
Accessible status without the karaoke
WCAG 2.1's Success Criterion 4.1.3 (Status Messages) is where most spinner implementations quietly fail. The pattern that works:
<div id="results" aria-busy="true">
<p role="status" class="status">Checking availability…</p>
<ul class="skeleton" aria-hidden="true">…</ul>
</div>
A few rules of thumb that save a lot of pain:
- Put the live region in the DOM before you need it. Injecting a
role="status"element at the same moment you set its text is unreliable across screen readers. - Announce milestones, not ticks. A live region that fires on every animation frame is a nightmare — the assistive tech equivalent of someone reading out the seconds on a stopwatch.
- Use
aria-busy="true"on the region being replaced, and flip it back when the content lands. It stops the screen reader narrating a half-built list. - Respect
prefers-reduced-motion. A spinner that ignores it is a genuine accessibility problem for people with vestibular disorders, and it's a two-line fix. - Don't let colour carry the message. Green tick plus the words "Saved" — never green tick alone.
The bandwidth reality nobody budgets for
Perceived performance on a council site is mostly a story about what you ship before the useful bit. A 280KB page with one well-sized image will feel instantaneous next to a 1.8MB page whose hero photograph is a drone shot of the civic centre.
Practical reductions that cost almost nothing:
- Fonts. One variable font with a proper
unicode-rangesubset, self-hosted withfont-display: swap. Or honestly, a system font stack — nobody has ever abandoned a bin collection request over a typeface. - Images. AVIF or WebP, correctly sized,
loading="lazy"below the fold, and never lazy-load the largest contentful paint image. That's a self-inflicted wound. - JavaScript. Anything not needed for the first interaction goes in a
deferortype="module"tag, not in the head.
You can't fix a slow API with CSS, and you shouldn't try. But you can stop a slow API from feeling like a broken site.
Testing this with five people and a stopwatch
You don't need a research budget. You need a throttled connection and twenty minutes.
- Throttle in DevTools. Slow 4G preset, 4× CPU slowdown. Record the page load in the Performance panel and note three numbers: when the main heading appears, when the first interactive control becomes usable, and whether anything shifts after the one-second mark.
- Run the five-person think-aloud. Grab five colleagues from outside the web team, give them a real task ("find out when your bins are collected"), throttle the connection, and ask them to narrate. Note where they sigh, where they re-click, where they say "is it doing anything?". That's your priority list, in order.
- Compare two versions. Serve the spinner variant and the skeleton variant behind a query parameter, alternate between them for a week, and compare task completion time in your analytics. Keep it simple enough that you don't need a statistician.
- Watch real user metrics by connection type. Core Web Vitals segmented by
navigator.connection.effectiveTypewill tell you whether your 3G visitors are a rounding error or a third of your traffic. On council sites, it's usually much higher than the team expects. - Don't trust a Lighthouse score on your laptop. It's a lab test on a fast machine over a fast connection. It tells you about your code, not about the person in the stairwell.
The checklist before you ship
- Every wait over one second has a visible, labelled state.
- No spinner exists without accompanying text.
- Skeletons match the real layout, or they don't get used.
- The status message exists in the DOM before it's needed, and
aria-busyis handled. - Reduced motion is respected.
- Long waits have a named stage or an honest time estimate.
- The heaviest thing on the page has been weighed in the last six months.
The goal was never to make the backend instantaneous. It's to stop making people guess — and a rotating circle has never once answered a question.
Next up in this series: measuring interaction latency on form-heavy pages without a full RUM rollout.