
AWS Blocks + Kiro Built My Voting App for $0 - Then I Tried to Break It
I handed Kiro one sentence and let its spec mode design a multiplayer voting app from scratch, including a bug it caught before I wrote a line of code. Then I attacked the finished guarantee live: concurrent votes, a killed server, and zero dollars spent on AWS.
Series: AWS User Group Madurai - Skill Sprint Challenge - DevTool Month - AWS Blocks (2 articles)
- 2AWS Blocks + Kiro Built My Voting App for $0 - Then I Tried to Break It This article
Introduction
Here's the sentence: a polling app where two people can never both win the same vote, no matter how the timing falls. That's it. That's the whole idea I started with.
I didn't write the app. I wrote that sentence down as a hard constraint, handed it to Kiro's spec mode, and got out of the way. What came back was a 25-requirement spec, a 14-diagram design, a 40-task build plan and, at one point, Kiro stopping mid-spec to tell me it wouldn't fake a claim I hadn't even asked it to make. Then, once the thing was actually running, I spent an afternoon trying to break it: ten concurrent votes fired at once from a facilitator console nobody asked for, a genuine race against a poll's closing deadline that I didn't stage and didn't expect, and a hard kill of the server mid-session just to see if the data would still be there when it came back up.
It was. This is that story.
Series: AWS User Group Madurai · Skill Sprint Challenge · Devtools Month · AWS Blocks (Part 2 of 3)
- Level 1 was one person, alone with their own data.
- Level 2 is a room full of strangers, all reaching for the same button at once.
Same sprint, same rules, no AWS account,
npm run dev. A completely different problem.The idea I actually handed off
The challenge brief itself boils down to one sentence buried in its concurrency notes: "two concurrent requests can both pass the read" if you don't reach for conditional writes. That's not a feature request, it's a warning about the one way this kind of app quietly lies to you, a vote count that looks right in every manual test and is wrong the instant two people click at once. So instead of asking Kiro to "build a polling app," I asked it to build one specific property and let everything else follow from it. My kickoff prompt didn't describe features in general terms, it dictated the vote handler's eight required steps almost verbatim, then added one line that mattered more than any of them: "A read-then-write is not acceptable here, say so explicitly in
design.md and explain why." Not "implement it correctly." Prove, in writing, before a line of implementation exists, that you understand why the obvious version is wrong.That's the whole handoff: one idea, phrased as a constraint, plus an instruction to read AWS Blocks' own installed documentation before writing anything, "over anything remembered from training data, the API may have moved on since Level 1 was built." Everything past that point, requirements, design, tasks, code, tests, the facilitator console nobody asked for, is what came back.
Why one sentence was enough
Buried in the challenge brief's fine print is a warning, not a feature request: "two concurrent requests can both pass the read" if you don't reach for conditional writes. That's the whole danger of this kind of app in one clause: a vote count that looks perfectly correct in every manual test you run, and is silently wrong the instant two people click at the same moment. You won't see it. Your tests won't see it. It'll just be wrong.
So I didn't ask Kiro to build a polling app. I asked it to guarantee one property and let everything else follow from that. My kickoff prompt spelled out the vote handler's eight required steps almost word for word, then added the one line that mattered more than all eight combined: "A read-then-write is not acceptable here, say so explicitly in
design.md and explain why."Not "implement it correctly." Prove you understand why the obvious version is wrong, in writing, before a single line of code exists.
That was the entire handoff. One constraint, plus an instruction to read AWS Blocks' actual installed documentation instead of guessing from memory, because "the API may have moved on since Level 1 was built." Everything else (the requirements, the design, the 40 tasks, the code, the tests, a facilitator console I never asked for) is what came back.
The moment the spec caught a bug before any code existed
requirements.md came first, and it opened by handing my one sentence back to me in sharper language than I'd used:The load-bearing property of the whole system is a correctness guarantee under concurrency: a single authenticated user can never have more than one vote counted on a single poll, and a vote can never be counted on a poll that has effectively closed, regardless of request timing. Every other feature exists to demonstrate or depend on that guarantee.
Twenty-five requirements grew out of that one sentence. I read all of them and signed off before a design document existed.
Here's the part I think is actually worth your time. During design (before
poll-api.ts had a single line written), Kiro worked through the state machine of the vote-versus-close race and found a hole. One of the requirements I'd just approved promised that a vote landing in the same instant a poll closes gets rolled back correctly. Turns out, under the feature set as scoped, that rollback could never fire:Under sweep-only closure, step (f) never fires. It becomes reachable throughclosePoll, the owner-only manual close … and it is whyclosePollexists in this design rather than being scope creep, without it, that acceptance criterion is untestable.
Read that again. A requirement I'd already signed off on turned out to be mathematically unreachable, because an earlier check in the same pipeline would always catch it first. The background sweep alone could never trigger the rollback path, so an entire method, a manual "close this poll now" button nobody had asked for, got added to the design for one reason only: to make an already-approved requirement testable. That happened before implementation, not during a code review after the fact. It's also the reason a screenshot later in this post shows a vote getting rejected in a way I did not plan, did not stage, and did not expect to catch on camera.
Design hit a second wall almost immediately after. The steering document I'd written earlier named exactly two database tables for this app. Kiro needed a third: rate-limit counters and an audit trail both need somewhere durable to live, and an in-memory counter resets itself every time the dev server restarts. Instead of quietly adding the table and moving on, it stopped:
This needs your explicit approval, because it edits a steering constraint. If you decline, Requirements 16 and 20 must be reduced to best-effort in-process counters, which do not survive restart and are not honestly "enterprise grade", I would rather drop the claim than fake it.
I approved it. What stuck with me wasn't the extra table. It was the alternative Kiro refused: ship the weaker version quietly and let the words "enterprise grade" cover for it anyway. The same instinct showed up once more, mid-build: a poll-listing endpoint the frontend needed but nobody had specified got added, and then retroactively written into the authorization matrix with its own test, citing a requirement written weeks earlier specifically to catch that kind of silent scope creep.
By the time the task plan existed, my one sentence had become 40 tasks across 11 phases, with a required gate blocking the facilitator console from being built at all until everything before it was green. The plan even reasons about parallelizing itself across sub-agents, and is honest about what that buys you: "If the goal is to shorten the concurrency work, waves will not do it." A planning document telling you the one thing it can't speed up is more useful than one claiming it accelerates everything evenly.
What this would have cost me without AWS Blocks
None of the above touched a real AWS account. Not the requirements, not the design, not one task in the plan. Every test run, every bug caught live, the whole spec-to-code loop: all of it ran on
npm run dev against local mocks, for $0, on a machine with no AWS credentials configured until I checked some in for this post.Picture building the same app the conventional way instead. A
polls table and a votes table in DynamoDB, hand-indexed so the close-sweep isn't scanning everything. A REST API behind API Gateway with a hand-written Lambda authorizer, because nothing hands you session auth for free. A second, WebSocket-flavored API Gateway for realtime, plus a connection-tracking table and the glue code to keep it consistent when someone's tab closes. An EventBridge rule and a Lambda for the close-sweep, with its own IAM role scoped tight enough to survive a security review.And here's the part that actually costs money, not just time: there's no local mock for API Gateway WebSocket APIs or EventBridge schedules. Testing whether your sweep actually closes a poll correctly means testing it against real AWS: a real deploy, a real DynamoDB read, a real Lambda invocation, every single time you touch the vote handler. Small money per call. Not zero money, multiplied by every iteration of building this thing.
npm run dev just doesn't have that problem.Level 1's real production deploy already put a number on what that hand-rolled operational layer looks like when Blocks generates it for you: CloudTrail recorded ten Lambda functions for a project with exactly one API handler. The rest was AWS Blocks' own machinery (a GSI manager, a secrets-init function, deployment helpers), the code a hand-rolled CDK stack needs someone to write and maintain. Here, the entire infrastructure declaration is three constructor calls:
new DistributedTable(...), new Realtime(...), new CronJob(...). Locally they resolve to disk-backed JSON, an in-process WebSocket server, and a setInterval. The moment a CDK synth walks the same file, they resolve to the real services above instead. Same code. Different context. That's the whole trick, and it's not a rewrite. It's conditional exports.The Blocks in this app
| Block | Locally, for $0 | If deployed |
|---|---|---|
AuthBasic | Disk-backed users, local JWT sessions | DynamoDB + signed JWT cookies |
DistributedTable ×3 (polls, votes, security) | JSON files under .bb-data/ | DynamoDB, conditional writes, a GSI on (status, closesAt) |
Realtime (results + integrity channels) | An in-process WebSocket server | API Gateway WebSocket API + DynamoDB |
CronJob | A setInterval that logs every tick | EventBridge + Lambda |
Scope / ApiNamespace | N/A | Eight typed RPC methods behind API Gateway |
AsyncJob, the sprint's other option for the background path, was considered and turned down on the record, not just left unused: "chosen over AsyncJob (Option B in the challenge brief) because deadline-driven auto-close is the more demoable behavior for a live audience." Database, FileBucket, AuthCognito, KnowledgeBase, and Agent all got the same treatment: named, and refused, with a one-line reason each.
this is where the $0 build actually starts. `npm run dev`, no credentials prompt, no account link, just the sign-in and create-account forms.
The vote handler design mode derived
This is
poll-api.ts's vote method, unedited. It's the direct descendant of the one sentence I started with: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
45
46
47
async vote(context: unknown, pollId: string, optionId: string) {
// ── step (a): authenticate FIRST, then capture a SERVER-side timestamp.
const user = await auth.requireAuth(context as never);
const votedAt = now();
// ...
await security.enforceLimit(user.userId, 'vote', votedAt);
// ── step (b): load the poll; reject missing / closed / past deadline.
const poll = await polls.getPollRecord(pid);
if (!poll) { throw notFound(); }
if (poll.status === 'closed') { await rejectVote(poll, user.userId, 'poll-closed', ...); throw pollClosed(); }
if (votedAt >= poll.closesAt) { await rejectVote(poll, user.userId, 'poll-closed', ...); throw pollClosed(); }
// ── step (c): the option must belong to THIS poll.
if (!poll.options.some((o) => o.optionId === oid)) { ...; throw invalidOption(); }
// ── step (d): RESERVE. The single point of arbitration.
try {
await votes.reserveVote(pid, user.userId, oid, votedAt);
} catch (e: unknown) {
// ── step (e): a conditional failure IS "already voted."
if (isBlocksError(e, DistributedTableErrors.ConditionalCheckFailed)) {
await rejectVote(poll, user.userId, 'already-voted', ...);
throw alreadyVoted();
}
throw e;
}
// ── step (f): re-read AFTER reserving, roll back a vote that lost the closure race.
const after = await polls.getPollRecord(pid);
if (after && after.closedAt !== null && after.closedAt <= votedAt) {
try {
await votes.rollbackVote(pid, user.userId, votedAt); // conditional on votedAt
} catch (e) { if (!isBlocksError(e, DistributedTableErrors.ConditionalCheckFailed)) throw e; }
await rejectVote(after, user.userId, 'poll-closed', ...);
throw pollClosed();
}
// ── step (g): bounded, conditional revision increment.
const result = await polls.bumpRevision(pid); // retries with jittered backoff
// ── step (h): full canonical snapshot, tagged with the new revision.
const snapshot = await live.computeSnapshot(result.poll);
await live.publishResults(snapshot);
await live.publishIntegrity(buildIntegrityEvent({ eventKind: 'vote-attempt', outcome: 'accepted', ... }));
return { accepted: true as const, revision: result.poll.revision };
}design.md flags one diagram as the one to put on screen during a demo. Here it is, reproduced exactly as generated:
Both requests pass every check before they collide. Both read "no vote exists." The table alone breaks the tie.
Step (d) has no pre-read: that's a rule, not an oversight. The comment in the source says so directly: "NO pre-read —
{ ifNotExists: true } is the mutual-exclusion primitive; the table enforces it." It was deliberately broken once, on purpose, just to check the tests would actually catch it. They did. Four of them, immediately.Step (f) (the rollback the design-phase analysis said would almost never fire) is exactly the path I hit for real. Not on a test poll built to demonstrate it. By accident, on a poll I created for something else entirely.

Poll page showing a red rejection banner, with the voting form still visible below it.

same instant, poll owner's own isolated session, no refresh. The Rejected counter is already at 1.

the live feed names the exact reason, five milliseconds after the attempt, tagged with a pseudonymous actor reference instead of a username.
Two people, two sessions, one live tally
Realtime runs two channels here (one for public tallies, one for the facilitator feed), and the channel names are computed server-side, never handed to the client to construct. The frontend follows two rules worth knowing if you're building something similar: subscribe and wait for the subscription to actually establish before fetching the poll, because a vote landing in that gap would otherwise be lost silently. And accept a pushed update only if its revision number is strictly higher than the last one applied, checked synchronously, so a slower fetch racing behind a faster push can never win and overwrite it.Two contexts converge in 96 to 316 milliseconds standalone, according to the project's own measurements. Not eyeballed. Measured.

a second poll, created to keep testing after the first one closed.

the poll list belongs to no one in particular. Every signed-in user sees the same open and closed polls.
To actually test two different people voting (not two tabs sharing one login, which is what most "multi-user" demos secretly are), I opened a second, genuinely isolated browser session, signed up as a second person, and voted from there.

a normal, accepted vote from the second identity.

Live Polls owner view showing a live tally at 1 vote (100 percent), a revision 1 badge, and Vote Integrity Console Accepted count of 1.
That last part matters more than it sounds. Two tabs of the same person updating each other is a nice trick. Two strangers updating each other is the actual thing this level asked me to prove.
So I tried to break it on purpose

Live Polls poll detail page with a two-option poll, a zeroed live tally, and a Vote Integrity Console panel showing four zeroed counters.
Everything up to this point proves the guarantee holds under normal use. It doesn't prove it holds under attack. That's what the facilitator console is actually for. I'd described it in my own kickoff prompt before Kiro built it: a button that fires ten concurrent vote requests from one identity through the real production
vote method, and shows live that exactly one wins.1
2
3
4
5
const settled = await Promise.allSettled(
Array.from({ length: RACE_TEST_FANOUT }, () =>
impl.vote(context, poll.pollId, poll.options[0].optionId),
),
);No shortcuts in that code. It's the same method every real voter calls.
RACE_TEST_FANOUT is a server-side constant, so no client can inflate the number, and the vote rate limit is deliberately set to three times that fan-out: otherwise the nine losers would fail on the rate limiter instead of the conditional write, and the demo would prove the wrong thing entirely.I pressed the button.

ten concurrent calls, one identity, the real `vote` method. One accepted. Nine rejected.

every one of the nine losers failed for the identical reason: `AlreadyVotedException`, from the conditional write.
Three real bugs, and the test-harness bugs that hid them first
There's an honest line in the commit history: "DoD gate walked live — GREEN. Three real bugs caught." Getting to that clean result took longer than it should have, because the test harness had three bugs of its own, and each one was quietly hiding the next.
A locator resolved to nothing in a real browser. Because the test config had no timeout set, that didn't fail. It just hung, silently, for the full five-minute budget. Once a 15-second timeout turned that into a precise error, the assertion underneath it turned out to be wrong too: it required the vote button to be enabled to prove a poll was open, when the button is legitimately disabled until someone picks an option. Three bugs, and none of them were the app's.
With the harness finally trustworthy, the real background sweep logged this, word for word:
1
2
[] poll open, due at 12:46:54 am; waiting for the sweep
[] sweep closed the poll and the screen flipped with no user actionBefore that fix, here's what it had actually caught. The frontend decided whether a poll was closed by reading a field that only updated on a manual re-fetch, so the server could publish closure over the realtime channel, the client would receive it, and just throw it away. That broke the entire background-close path from the audience's perspective: a poll the CronJob had already closed would sit there looking perfectly interactive on every screen that hadn't manually refreshed. A second bug, found the same session: two form fields shared one generated accessibility id, so a screen reader announced both as "Closes at," indistinguishable, and missed by every automated test because none of them compute the accessible name the way a real browser does. A third: the sign-out button skipped the app's own auth state machine entirely, so signing out didn't actually re-lock the screen. A human found that one by clicking sign out, not by running a test.
All three are fixed now, with regression tests to prove it. And the verification didn't stop once that gate went green: a poll-list query that scanned two full table partitions while still returning the right answer, a compiler version that wasn't pinned, a facilitator feed that quietly double-counted under React's strict mode. Every one of them is a reminder that a passing test suite is a floor, not a ceiling.
Then I killed the server on purpose
Everything up to here ran in one continuous session. To actually check the durability claim, I hard-killed the entire process tree, not a graceful shutdown, a
taskkill, and brought it back up.
the moment before the kill. Two polls, one closed, the other at revision 2 with two votes counted.

Live Polls page after a server restart showing the same poll at revision 2 with two votes counted and a proper message.
The full verification suite (unit, accessibility, and browser tests together) passes at 357 out of 357, nothing failed, nothing skipped. Three more browser specs run separately on purpose, because they each need something the routine suite can't give them: two need a real server restart mid-test, one needs to sit and wait through two real 60-second background-job ticks. All three pass. None of them are quietly left out.
The parts I didn't get to brag about
A voter's choice stays private even from the poll's owner, enforced by the shape of a schema rather than a promise: the event that streams to the facilitator console simply has no field for which option was chosen, so there's nothing to leak even by accident. Every one of the eight methods this API exposes checks who's calling before it does anything else, and a test enforces that at runtime by checking the live API object itself, so a ninth method added later without that check fails the build automatically.
This build stayed local on purpose. The brief for this level doesn't require an AWS account, unlike Level 1, where deploying was optional and I did it anyway. So the honest cost figure here really is zero, not because anything got skipped, but because nothing in this build ever touched real AWS. What's genuinely still open: the audit trail is tested for the mechanism, not for completeness, and nothing proves every mutating action writes an audit row, the way the authorization check is proven complete. If you want a harder starting point than "clone and run," close that gap yourself and I'll credit it in an update. There's no lockout on repeated sign-in attempts, and no transport encryption locally, both accepted risks the design doc names outright rather than glosses over, and neither is fine the moment this goes past a workshop.
Try it yourself
1
2
3
4
git clone https://github.com/yogeshselvarajan/live-polls
cd live-polls
npm install
npm run dev # localhost:4000, no AWS account neededTwo genuinely separate browser sessions, not two tabs of one login. Sign up as two different people, vote from one, watch the other update with nothing refreshed. Then open your own poll and press Run race test: ten concurrent votes against yourself, one winner, nine rejections, live, in front of you.
Built with AWS User Group Madurai
Part 2 of a series built for AWS User Group Madurai 's Builders Skill Sprint (June 2026, Devtools Month), entirely on AWS Blocks , released into public preview just weeks before the sprint started. Level 3 is next: Cognito, file uploads, and an AI help desk with a knowledge base and an agent. Same format: hand off an idea, let the process cover it, push past it, verify everything yourself.
One more thing
AWS Blocks was in public preview when this was written, about eight weeks after its June 2026 launch. If you're reading this much later, check the current documentation before assuming any of the above still matches exactly.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Series: AWS User Group Madurai - Skill Sprint Challenge - DevTool Month - AWS Blocks (2 articles)
- 2AWS Blocks + Kiro Built My Voting App for $0 - Then I Tried to Break It This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article