
I killed an AWS region on purpose: building a disaster check-in app on Aurora DSQL
How I built a disaster check-in PWA that survives the outages it's meant for — Amazon Aurora DSQL, active-active across two regions, with the failover demonstrated, not claimed. I created this blog post for the purpose of entering the H0: Hack the Zero Stack hackathon. #H0Hackathon
Disaster response apps have an ironic problem — they run in a single cloud region.
Recently, AWS us-east-1 went down for hours. It took Wealthsimple, Disney+, Venmo, and large portions of the internet with it. Could you imagine that happening during an earthquake? The very platform people are using to mark themselves safe...gone, because it ran in the same region the disaster disrupted.
Aftershock is my attempt to fix that.
Aftershock is a disaster check-in platform. When an earthquake or severe-weather event hits, individuals mark themselves
Safe or Need Help in one tap. Org admins - employers, universities, community groups see their full roster in real time: who's accounted for, who needs help, who hasn't responded. Companies have a legal duty of care to account for employees during emergencies. Everbridge and AlertMedia have built solid businesses on this — enterprise contracts, six figures a year.I wanted to build something that could survive the same disasters it was designed for. So I built Aftershock on Amazon Aurora DSQL, active-active across two AWS regions. When us-east-1 goes down, us-east-2 keeps writing. No primary, no failover delay, no data loss.
Here's the whole system at a glance — live feeds in, four screens out, Aurora DSQL active-active at the core:

Why Aurora DSQL and not the obvious alternatives
When building a multi-region AWS app, DynamoDB is the best contender specifically DynamoDB Global Tables. It's a natural fit because it includes global replication, active-active writes & infinite scale.
However, DynamoDB Global Tables uses last-writer-wins conflict resolution which means if two regions receive conflicting writes for the same item close in time, the write with the later timestamp wins silently, with no error.
Although there are workarounds such as version vectors, stream-based conflict detection, none of these are native. You're handling it in application code.
A visualization might help: A user checks in as
NEED_HELP from a damaged area where connectivity is intermittent. A few seconds later, a stale replication packet from us-west-2 carries an older I'm Safe status. Last-writer-wins means the NEED_HELP record gets silently overwritten. The org admin's dashboard shows the person as safe. Nobody dispatches a responder. User A is left to their own devices.Aurora DSQL fixes this with synchronous strong consistency. Think of it as a TLS handshake between regions — both sides agree before any write is committed, so transaction conflicts never happen.
Aurora DSQL and DynamoDB Global Tables make different bets at each of these pressure points:
| Concern | Aurora DSQL | DynamoDB Global Tables |
|---|---|---|
| Concurrent writes, same user | With OCC, DSQL detects write conflicts and returns an explicit error. If 2 writes hit the same row at the same time, one commits and the other gets an explicit error and must retry. | Last-writer-wins — older write can silently overwrite newer |
| SQL joins for org roster rollup | Full PostgreSQL semantics | No joins — requires denormalization or multiple reads |
| Active-active writes | Both regions writable, strongly consistent | Both regions writable, eventually consistent |
| Cost at scale | Serverless, scales to zero | On-demand, similar low-volume cost |
OCC has a tradeoff: when many requests race to update the same row, retries pile up. A ticketing system selling the last seat or a flash sale decrementing inventory would feel that. Aftershock doesn't have that problem because every check-in is a new row, so no two users ever compete for the same write.
The data model
DSQL has specific constraints that shaped every decision: no foreign keys, no triggers, no sequences, no stored procedures. That means every table gets a client-generated UUIDv7 as its primary key, and referential integrity lives in the application layer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
CREATE TABLE groups (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL
);
CREATE TABLE members (
id UUID PRIMARY KEY,
group_id UUID, -- app-enforced, no FK constraint
name TEXT NOT NULL,
email TEXT,
label TEXT, -- dashboard filter (North block, Eastside, etc.)
home_lat DOUBLE PRECISION,
home_lng DOUBLE PRECISION
);
CREATE TABLE hazards (
id UUID PRIMARY KEY,
source TEXT NOT NULL, -- 'USGS' | 'NWS'
external_id TEXT NOT NULL UNIQUE, -- idempotent ingest key
type TEXT NOT NULL, -- 'earthquake' | 'tornado' | 'flood'
severity TEXT, -- 'moderate' | 'severe' | 'extreme'
title TEXT, -- "M5.8 - 12km NE of San Jose"
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
radius_km DOUBLE PRECISION, -- quakes: derived from magnitude
geometry JSONB, -- NWS polygons, hit-tested client-side
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
ingested_at TIMESTAMPTZ DEFAULT now(),
raw JSONB
);
CREATE TABLE checkins (
id UUID PRIMARY KEY,
member_id UUID NOT NULL,
hazard_id UUID NOT NULL,
status TEXT NOT NULL, -- 'SAFE' | 'NEED_HELP'
note TEXT,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
region_written TEXT NOT NULL, -- 'us-east-1' | 'us-east-2'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);My first design for the
check-in table had a users.current_status field. I scrapped it after realizing that a single status field overwrites history. If someone checks in safe at 14:02 and calls for help at 14:40, you'd only ever see the latest i.e. the earlier check-in is gone.Storing each check-in as its own row solves both problems. The full timeline is preserved, and since every write goes to a new row rather than updating an existing one, two simultaneous check-ins from the same user can never collide. Under DSQL's OCC, conflicting writes on the same row trigger retries — with append-only inserts, there's nothing to conflict over. The latest status is just
ORDER BY created_at DESC LIMIT 1.I added a
region_written column to every check-in. It records which region handled the write. When both regions are active, you see us-east-1 and us-east-2 tags interleaving across rows. Kill east-1 and every new write comes from us-east-2. Restore it and they interleave again.DSQL doesn't support PostGIS, so geographic queries work differently. To check if a user is near a hazard, the app first runs a bounding box query — a rough rectangular filter using a composite index on
(lat, lng) that quickly eliminates most rows. From that smaller set, haversine verification kicks in. It calculates the shortest distance between two points on a sphere, correcting for the Earth's curvature to confirm the user is actually within the hazard radius. For NWS alert polygons, the raw GeoJSON is stored in a JSONB column and shipped to the client to render on the map.TLDR: bounding box to filter fast, haversine to confirm accurately.
Because every check-in is its own row, the full org roster — who's safe, who needs help, who hasn't responded — is a single query. Latest status per member:
ORDER BY created_at DESC LIMIT 1.1
2
3
4
5
6
7
8
9
10
11
12
SELECT
m.id, m.name, m.label,
c.status, c.region_written, c.created_at AS checked_in_at
FROM members m
LEFT JOIN LATERAL (
SELECT status, region_written, created_at FROM checkins
WHERE member_id = m.id AND hazard_id = $2
ORDER BY created_at DESC
LIMIT 1
) c ON true
WHERE m.group_id = $1
ORDER BY m.name;Indexed on
checkins(member_id, hazard_id, created_at). The dashboard polls every 5 seconds — the index keeps it cheap.Wiring live USGS and NWS feeds
Two free government APIs, no API key required for either.
USGS Earthquake Hazards Program publishes real-time GeoJSON feeds updated every minute:
https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson— all quakes in the past hour, M2.5 and above kept (anything below is noise)
Each feature has a stable
id field. We store it as external_id and upsert on it, so the ingester can run on a schedule without creating duplicates.NWS Weather Alerts API is similarly clean:
https://api.weather.gov/alerts/active- Requires a
User-Agentheader identifying your app (Aftershock-H0Hackathon (your@email.com)). Remember to set this or requests get rejected. - Aftershock filters to
SevereandExtremeseverity only. Lower-severity alerts cover too wide an area to be actionable.
The ingester runs as a Vercel Cron job. Each run fetches both feeds, upserts new events, and expires old ones. The whole thing is ~80 lines and idempotent safe to re-run indefinitely.
1
2
3
4
5
6
7
8
9
// Earthquake magnitude → impact radius (approximate)
function magnitudeToRadiusKm(mag: number): number {
if (mag \>= 7) return 400;
if (mag \>= 6) return 200;
if (mag \>= 5) return 100;
if (mag \>= 4.5) return 60;
if (mag \>= 3.5) return 30;
return 15;
}One practical note: the NWS API returns GeoJSON polygons for weather alerts. These can be complex multi-polygons covering irregular county boundaries. Store the raw GeoJSON and render it on the client rather than simplifying it server-side. The polygons cap out at a US state in size, so the client handles them fine.
The failover wrapper
The entire multi-region behavior lives in
src/lib/aftershock/db.ts. The wrapper knows both regional endpoints — try us-east-1, and if it can't connect, us-east-2 picks it up automatically.1
2
3
4
5
6
7
8
9
10
11
const ENDPOINTS = [
{ region: "us-east-1", host: process.env.DSQL_ENDPOINT_EAST1! },
{ region: "us-east-2", host: process.env.DSQL_ENDPOINT_EAST2! },
]
// Set KILL_REGION=us-east-1 to remove that endpoint from the active list
function activeEndpoints() {
const killed = process.env.KILL_REGION
const list = ENDPOINTS.filter(e =\> e.region !== killed)
return list.length \> 0 ? list : ENDPOINTS
}KILL_REGION removes an endpoint from the active list without touching the cluster. It's how you simulate a regional failure without actually taking anything down.DSQL IAM auth tokens expire hourly. The wrapper generates a fresh token per connection. In a serverless environment, connections are short-lived anyway, so there's no overhead and no risk of a mid-request expiry.
Three DSQL behaviors that surprised me:
No
SELECT FOR UPDATE. DSQL uses Optimistic Concurrency Control instead of row-level locks. If your write pattern depends on lock-then-update, restructure it as a single atomic UPDATE with the conditions in the WHERE clause. For Aftershock's append-only check-ins, it never came up — every write is a new row.Indexes build asynchronously. Run them at schema setup so they're ready before the app goes live.
Schema changes replicate too. I expected data writes to sync across regions. What I didn't expect was DDL sync. When you apply the schema to us-east-1, us-east-2 gets the same tables and indexes automatically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
=== us-east-1 ===
indexname | tablename
---------------------------------------------+-----------
checkins_hazard_id_created_at_idx | checkins
checkins_member_id_hazard_id_created_at_idx | checkins
checkins_pkey | checkins
groups_pkey | groups
groups_slug_key | groups
hazards_external_id_key | hazards
hazards_ingested_at_idx | hazards
hazards_lat_lng_idx | hazards
hazards_pkey | hazards
idx_hazards_expires | hazards
members_group_id_idx | members
members_pkey | members
(12 rows)
=== us-east-2 ===
indexname | tablename
---------------------------------------------+-----------
checkins_hazard_id_created_at_idx | checkins
checkins_member_id_hazard_id_created_at_idx | checkins
checkins_pkey | checkins
groups_pkey | groups
groups_slug_key | groups
hazards_external_id_key | hazards
hazards_ingested_at_idx | hazards
hazards_lat_lng_idx | hazards
hazards_pkey | hazards
idx_hazards_expires | hazards
members_group_id_idx | members
members_pkey | members
(12 rows)When a region goes down
DSQL runs active-active across us-east-1 and us-east-2. If us-east-1 becomes unreachable, the failover wrapper catches the connection error and retries us-east-2. Check-ins keep landing — users never see a failure.
Behind the scenes: the activity feed tags flip from interleaved
us-east-1 / us-east-2 to all us-east-2. When us-east-1 comes back, they interleave again.
What v0 got right and wrong
I started this project with little Next.js experience. In past hackathons, I'd spend hours piecing React components together. v0 changed that.
I'd drop in a prompt describing the screen and specify the exact file it should create. The hazard map: "full-bleed dark map, pulsing earthquake circles sized by magnitude, NWS alert polygons in tornado red and flood blue, side panel with severity-sorted event list." The org dashboard: roster table, status badges, filter dropdowns, live feed panel. Both came back shippable on the first try. That wasn't my experience a year ago. Back then it took 2-5 iterations to get something working. Now it reads your project files, installs the right packages, and scaffolds the full component tree in one shot.
Here's v0 mid-build on the hazard map — reading the project files, installing MapLibre, scaffolding the component tree

And the output — a dark-mode map with live USGS/NWS data, severity-coded hazard list, and mission-control styling:

I built this backwards so the wiring was on me. I had the backend done before v0 touched anything, so every time I pasted a screen in, something didn't line up. The API shapes were different from what v0 assumed and files landed in the wrong place. I had to skip
globals.css and layout.tsx every single time because v0 would regenerate them and wipe out the font setup. None of that is a v0 failure. It's what happens when you hand a scaffolding tool a backend it's never seen. Build UI-first next time and the backend to match or if you like patching, be like me.
Vercel has a native Aurora DSQL integration under Settings → Integrations. Its Marketplace flow can provision a cluster for you: you'd pick a region and plan, and Vercel creates it and manages its lifecycle. I already had a multi-region cluster in my own AWS account, so I brought my own account. That meant the auth was on me: an IAM user with dsql:DbConnectAdmin on the cluster ARN, plus
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and the regional endpoints set in Vercel's environment variables. The app mints a short-lived token from those credentials on every connection. If you'd rather not store static keys, Vercel's connect-account flow uses OIDC federation to mint short-lived credentials instead.

Try it
Live app: https://aftershock-beta.vercel.app/
GitHub: https://github.com/coolchigi/Aftershock
Built with: Amazon Aurora DSQL (active-active, us-east-1 + us-east-2), Next.js on Vercel, v0 scaffolding, USGS Earthquake Hazards Program feed, NWS Weather Alerts API.
Created for the H0: Hack the Zero Stack hackathon . #H0Hackathon
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article