How to Build a Web App That Survives Easter Traffic Spikes

Build for the spike before it hits: cap your database pool correctly, move email and payment calls out of the request path, and keep serverless functions warm. Load-test with real concurrency using k6, not browser refreshes. Measure where it breaks, fix that layer, then test again.
Part of the complete app development guide.
On this page
- Understand what a spike actually does to your stack
- Move slow work out of the request
- Cache the things that don’t change
- Tune your database connection pool
- Have a degraded mode ready
- Test the whole path, not just the endpoint
- What this looks like for a small local business
- FAQ
- How much traffic counts as a spike?
- Do I need to load-test in production?
- What’s the fastest thing I can do this week?
Every year around the first week of April, a certain kind of business gets slammed. Restaurant reservation systems, church event pages, florist ordering forms, egg hunt signup sheets. The load isn’t steady, it’s a spike. If your app wasn’t built for that, you find out the hard way at 9am on Easter Sunday. Here’s how I think about building and testing for predictable traffic spikes, whether you’re on Long Island or anywhere else.
Understand what a spike actually does to your stack
A traffic spike doesn’t just add requests. It exposes whatever your weakest layer is. That’s usually one of these:
- Database connections. A pool of 10 connections serving 500 concurrent users means 490 people wait in line. Most frameworks default to a pool size that’s fine for development and useless in production.
- Synchronous work in the request path. Sending an email, calling a payment API, or generating a PDF while the user waits holds a worker open. Under load, workers run out and requests queue.
- Cold starts. Serverless functions that were warm all week go cold when traffic drops overnight, then spike hard in the morning.
- Third-party rate limits. Your SMS provider, your email service, your maps API. They don’t care that it’s Easter.
You can’t fix what you haven’t measured, so start by measuring. Load-test with a tool that can actually simulate concurrency, not just a browser refresh loop.
# k6 is my default for this. Simple, scriptable, real concurrency.
# Save as spike.js and run: k6 run spike.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
easter_morning: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 50 }, // normal traffic
{ duration: '1m', target: 400 }, // the spike
{ duration: '5m', target: 400 }, // hold it
{ duration: '2m', target: 0 }, // wind down
],
},
},
thresholds: {
http_req_duration: ['p(95)<800'], // 95% of requests under 800ms
http_req_failed: ['rate<0.01'], // less than 1% errors
},
};
export default function () {
const res = http.get('https://your-app.example.com/api/availability');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
Run that against staging, not production, and watch where it breaks first. That tells you what to fix.
Move slow work out of the request
The single biggest win for spike resilience is making the user-facing request do as little as possible. Queue the slow stuff.
// Before: the user waits for the email to send.
app.post('/reserve', async (req, res) => {
const reservation = await db.reservations.create(req.body);
await mailer.sendConfirmation(reservation); // 300-900ms, sometimes worse
res.json(reservation);
});
// After: the user gets a response immediately, the email goes to a queue.
app.post('/reserve', async (req, res) => {
const reservation = await db.reservations.create(req.body);
await queue.publish('send-confirmation', { id: reservation.id });
res.status(201).json(reservation);
});
Now if the email provider is slow or down, your reservation endpoint still responds in milliseconds. The queue retries on its own schedule. On a spike day, that difference is the whole ballgame.
Cache the things that don’t change
Availability calendars, menus, event schedules, service lists. Most of that data changes a few times a day at most, but gets read thousands of times during a spike. Cache it at the edge or in memory with a short TTL.
// Cache a read-heavy endpoint for 60 seconds.
// Stale data for a minute beats a dead server for an hour.
const cache = new Map();
async function getCached(key, ttlMs, fetchFn) {
const hit = cache.get(key);
if (hit && Date.now() - hit.time < ttlMs) return hit.value;
const value = await fetchFn();
cache.set(key, { value, time: Date.now() });
return value;
}
app.get('/api/events', async (req, res) => {
const events = await getCached('events:upcoming', 60_000, () =>
db.events.findMany({ where: { date: { gte: new Date() } } })
);
res.json(events);
});
If you’re on a platform with a built-in cache (Cloudflare, Vercel, Fastly), use that instead. It survives deploys and scales past whatever your process can hold.
Tune your database connection pool
This is where a lot of small apps fall over. The default pool size in most ORMs is somewhere between 5 and 10. If your app runs 4 instances, that’s 20 to 40 connections total. Postgres on a small managed plan often caps out around 100. Do the math before the spike does it for you.
- Set the pool size explicitly. Don’t rely on defaults.
- Add a connection timeout so requests fail fast instead of hanging.
- Consider a pooler like PgBouncer or your provider’s built-in one if you’re running many instances.
- Watch for long-running queries holding connections. Add a statement timeout.
Have a degraded mode ready
You don’t need the app to do everything at full capacity during a spike. You need it to do the important thing. Decide in advance what gets cut:
- Disable recommendation widgets and “you might also like” sections.
- Turn off non-critical logging and analytics calls in the request path.
- Serve a cached version of read-only pages.
- Queue or defer anything that isn’t a core transaction.
A feature flag that flips these off under load takes an afternoon to build and saves you a weekend of firefighting.
Test the whole path, not just the endpoint
Your app might handle 400 concurrent users fine while your payment provider rate-limits you at 50 requests per second, or your SMS gateway throttles you at 10. Load-test the integrations too, or at least read their limits and add backpressure on your side. A queue that respects a provider’s rate limit is better than a retry storm that gets you blocked.
What this looks like for a small local business
If you’re running a reservation system for a restaurant in Commack or an event page for a church in Huntington, you probably don’t need Kubernetes. You need a queue for the email, a cache for the read-heavy stuff, a sane connection pool, and a load test that proves it holds. That’s it. The work is boring and it works.
If you’re building something like this and want a hand, I do custom web development for small and mid-sized businesses, and most of the value is in exactly this kind of pre-spike hardening. Reach out if you want to talk through your setup.
FAQ
How much traffic counts as a spike?
It depends on your baseline. If you normally see 5 requests per second and Easter pushes you to 150, that’s a 30x spike and you need to plan for it. The number matters less than the ratio and how long it holds. A 30x spike for 10 minutes is survivable with caching. A 30x spike for 6 hours needs real architecture work.
Do I need to load-test in production?
No. Test in staging with production-like data sizes and the same infrastructure shape. If staging has 100 rows and production has 2 million, your test will lie to you. Copy a realistic data volume, or at minimum test the queries that scan the most rows.
What’s the fastest thing I can do this week?
Move any email, SMS, or PDF generation out of the request path and into a queue. That single change usually cuts response times by half or more under load and removes the most common cause of timeouts during a spike.


