Performance is not a technical afterthought. It's a design decision.
When we launched LEGALIZE-PE, a platform to make Peruvian legislation more accessible, we were excited. The interface was clean, the content was structured, and everything worked. But then we looked at the numbers.
18 seconds to First Contentful Paint.
That's not slow. That's painful. And when you're building a civic tool meant to serve people on varying network conditions, that's a barrier to access.
So we set a goal: cut load time to under 3 seconds. Not because it was a nice-to-have. Because speed is part of usability.
The audit: where the time actually went
Before optimizing anything, we needed to understand where those 18 seconds were going. We ran Lighthouse audits, analyzed Network waterfalls, and profiled bundle sizes.
The problems were clear:
- Massive JavaScript bundle: 1.2MB of unoptimized client-side code
- Sequential data fetching: Each page component waited for the previous one to finish
- No caching strategy: Every visit re-fetched the same legislative data
- Unoptimized images: Full-resolution PNGs when we could use WebP at 1/3 the size
None of these were mysterious. They were just never prioritized. Performance had been treated as something to "fix later."
But later is now.
Optimization 1: Incremental Static Regeneration (ISR)
The biggest win came from rethinking how we served content.
Legislative data doesn't change every second. Bills get published, debated, and updated over days or weeks. So why were we fetching this data on every request?
We moved to Next.js ISR, which generates static pages at build time but allows them to refresh in the background when new data is available.
// Before: Client-side fetch on every load
export default function BillPage({ id }: { id: string }) {
const [bill, setBill] = useState(null);
useEffect(() => {
fetch(`/api/bills/${id}`)
.then(res => res.json())
.then(data => setBill(data));
}, [id]);
if (!bill) return <Loading />;
return <BillContent bill={bill} />;
}
// After: Static generation with ISR
export async function generateStaticParams() {
const bills = await fetchAllBills();
return bills.map(bill => ({ id: bill.id }));
}
export default async function BillPage({ params }: { params: { id: string } }) {
const bill = await fetchBill(params.id);
return <BillContent bill={bill} />;
}
export const revalidate = 3600; // Revalidate every hourThis single change dropped our Time to First Byte (TTFB) from 2.8s to 0.4s.
Optimization 2: Bundle splitting and lazy loading
Next, we tackled the JavaScript bundle. We were shipping a 1.2MB bundle that included everything: charts, PDF viewers, Markdown renderers, and third-party analytics.
Most users never touched those features. So why make them pay the cost upfront?
We implemented code splitting with dynamic imports:
// Before: Everything imported at the top
import Chart from './chart';
import PDFViewer from './pdf-viewer';
import MarkdownRenderer from './markdown-renderer';
// After: Lazy load on demand
const Chart = dynamic(() => import('./chart'), {
loading: () => <ChartSkeleton />,
ssr: false
});
const PDFViewer = dynamic(() => import('./pdf-viewer'), {
loading: () => <ViewerSkeleton />
});We also deferred third-party scripts until after hydration:
// Analytics and monitoring loaded after initial render
useEffect(() => {
const timer = setTimeout(() => {
import('analytics').then(module => module.init());
}, 2000);
return () => clearTimeout(timer);
}, []);Result: Initial bundle dropped from 1.2MB to 320KB. Interactive time cut in half.
Optimization 3: Edge caching with Vercel
The final piece was distribution. Even with ISR, users far from our origin server in São Paulo were still experiencing latency.
We configured Vercel's Edge Network to cache and serve pages from the nearest location to each user.
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800',
},
],
},
];
},
};This meant users in Lima, Cusco, or Arequipa all got sub-second response times.
The results: Core Web Vitals before and after
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| First Contentful Paint | 18s | 3s | 6x faster |
| Time to Interactive | 21s | 4.2s | 5x faster |
| Largest Contentful Paint | 19s | 3.1s | 6.1x faster |
| Cumulative Layout Shift | 0.18 | 0.02 | 9x better |
But numbers only tell part of the story. What mattered more was user feedback. People who previously abandoned the site were now using it regularly. Teachers started assigning it in civics classes. Journalists referenced it in articles.
Speed became an enabler of access.
Performance is a design decision
Here's what I learned: performance optimization is not just about choosing the right technical pattern. It's about designing with constraints in mind from the start.
Every image, every font, every dependency is a choice that affects load time. And load time affects whether someone can even use your product.
When you're building civic technology, or anything meant to serve diverse users on different devices and networks, performance is not optional. It's foundational.
Because a beautiful interface that takes 18 seconds to load is not usable. It's not accessible. And it's not serving the people it's meant to help.
Performance is UX. And UX is design.