Japan sits on four tectonic plates. Most people who live here carry that fact quietly in the back of their mind, and every so often it stops being background noise. On July 28, 2026, an earthquake hit Kumamoto and people lost their lives. I want to say that plainly before anything else in this post: my thoughts are with the people who were killed and the people still living with what that day did to their homes and their sense of safety. Everything technical that follows exists because of days like that one.
I build small AI-agent side projects most weekends, and this time I wanted to build something that actually mattered to me personally rather than something clever for its own sake: a real-time earthquake alert app. Text only, no map to stare at, just "something is happening, here's where, here's how strong." I built it end to end — WebSocket ingestion on AWS, a Strands Agent on Amazon Bedrock AgentCore Runtime writing the alert copy in six languages, a PWA on Vercel that plays a different alert sound depending on severity. Then, while I was still testing it, an actual earthquake swarm started in Kumamoto and the app got tested by real data whether I liked it or not. More on that below.
What it does
The rules are simple on purpose:
- Shows anything of shindo (Japanese seismic intensity) 1 or higher
- Shindo 5-weak and above, Earthquake Early Warning (EEW), and tsunami warnings get a red, emphasized card
- A map of Japan appears only for those emphasized cases, with the affected prefectures highlighted — no map at all for routine shindo 1-2 reports, because most of the time a map adds noise, not signal
- Everything is offered in Japanese, English, Chinese, Korean, Russian, and Arabic, with right-to-left layout for Arabic
- Push notifications work even when the browser tab is closed, using a real audio file rather than the browser's default ping
None of this is exotic technology. What made it interesting to build was how many small, real-world details turned out to matter.
Architecture
A Fargate task in ap-northeast-1 holds a persistent WebSocket connection to P2P Earthquake Information, a free, community-run relay of Japan Meteorological Agency (JMA) data. When a message comes in, the connector filters it, normalizes it into a common shape, and hands the structured data to a Strands Agent running on Amazon Bedrock AgentCore Runtime. The agent's only job is to turn "maxScale: 45, areas: [Wajima, Suzu], tsunami: advisory" into a short, calm headline and summary — in all six languages, in a single call. The result gets written to DynamoDB and pushed to every subscribed browser over Web Push. On the other side, a Next.js PWA on Vercel reads events through a small API Gateway/Lambda layer and renders the live feed.
I picked Fargate for the connector specifically because a WebSocket listener needs to stay alive, and Lambda's execution model doesn't fit that. Everything else — the API routes, the event storage, the translation — is about as serverless as I could make it.
The parts that didn't work on the first try
The EEW payload lied to me by omission. I assumed the areas array in a P2P quake "555" message (Earthquake Early Warning) would contain municipality-level shindo estimates, the way the finalized "551" earthquake info does. It doesn't. It's peer relay counts inside the P2P network itself — completely unrelated to the earthquake. My first build rendered a wall of "undefined (shindo 1)" entries in production before I caught it and rewrote that part to only pull real detail from the confirmed 551 message that (usually) follows.
A SigV4 signature bug cost me most of an evening. I'm calling AWS API Gateway from a Vercel function using OIDC federation — no static AWS keys, just a role Vercel assumes per request. Requests without a query string worked fine. Requests with one (?limit=30) came back 403 every time. IAM policy simulation said "allowed." Assuming the role manually from my own machine and signing the exact same request worked. The difference turned out to be embarrassingly simple: I'd built the query string directly into the request path instead of passing it through the signer's dedicated query field, so the signature covered a URL that didn't match what actually went out over the wire.
ARM64 vs x86_64. I build the connector's Docker image on an Apple Silicon Mac. Fargate defaults to x86_64. The container built fine, pushed fine, and then died on startup with exec format error. One line (runtimePlatform: { cpuArchitecture: ARM64 }) fixed it, but it took a failed deployment to notice.
A chicken-and-egg secret. The Fargate task reads its VAPID (Web Push) keys from Secrets Manager. If you let CDK create that secret fresh, it initializes with a random placeholder value, and the container crashes trying to parse it as JSON before you ever get a chance to set the real keys. The fix was to create the secret with real values first, then have CDK import it instead of creating it.
iOS wouldn't let audio play. Mobile Safari and Chrome (both WebKit under the hood on iOS) block audio.play() unless it's called synchronously inside a real user gesture. A push notification arriving is not a user gesture, no matter how you slice it. The fix is a small trick: play a near-silent sound the moment the user taps "enable notifications," which unlocks the audio element for later programmatic playback in that same tab.
A timezone bug that hid inside a feature I was proud of. I added a safety feature: if an EEW alert isn't followed by confirmed earthquake details within 10 minutes, quietly relabel it "unconfirmed" instead of leaving it looking urgent forever. It shipped, and it did nothing. The bug: P2P's timestamps look like 2026/08/12 10:40:03, already in JST, with no timezone marker. Passed straight into new Date() inside a UTC-timezone container, that string gets interpreted as UTC — silently shifting every event nine hours into the future. now - eventTime was permanently negative, so the 10-minute check never once fired. I only found it because real alerts sat "urgent" for over an hour and someone (me) asked "why hasn't this changed."
Then a real swarm showed up
While I was mid-build, Kumamoto started experiencing a real earthquake swarm — dozens of quakes over more than sixteen hours, several strong enough to trigger EEW. My test app, still very much a work in progress, started receiving genuine alerts back to back. It was uncomfortable in a way a synthetic load test never is: I was watching a tool I'd built for exactly this situation get exercised by the real thing while I was still finding bugs in it. It's also the reason the "unconfirmed" feature and the timezone bug above both got fixed in the same afternoon — nothing motivates a fix like watching your own notification fire for the fourth time in twenty minutes with no idea if it's real.
What I'd tell past me
Test with real, messy, live data as early as possible. Every bug above — the EEW payload shape, the SigV4 query string, the timezone parsing — was invisible in my own mocked test data and obvious the moment real traffic hit it. Mocks are useful for exercising code paths, but they can't tell you that your assumptions about a third-party API's shape are wrong, and they definitely can't tell you your timestamp parsing silently breaks in a specific timezone.
The stack, if it's useful to anyone building something similar: AWS Fargate, Amazon DynamoDB, Amazon API Gateway, AWS Lambda, Amazon Bedrock AgentCore Runtime running a Strands Agent, Next.js on Vercel with OIDC federation for keyless AWS access, and Web Push with VAPID for notifications.
It's a small app. It won't stop an earthquake. But if it gets one person a few extra seconds of warning, or tells someone in a language other than Japanese that the shaking they just felt was real and roughly how strong, that's the whole point of having built it.



Top comments (0)