I built a serverless handwriting-grading app for my own kids, and it turned into one of the most honest reliability projects I've worked on. This is the deep version... the event-driven internals, the throttling war story, and the reconciliation layer, straight out of the deployed code.
Every week my kids bring home a spelling list. And every week the same thing happened... they'd ace the open-book practice, then forget some words and write others incorrectly. Letters flipped. Words running together. "Because" spelled a different way every time they tried.
I'm an engineer, so I built a thing. It's called Bee Hunter, and I'll be straight with you... it taught me more about the sharp edges of event-driven systems than half the production services I've worked on. My son named it: "Bee" for spelling bee, "Hunter" because that's our last name. He was very proud, and I was too.
Here's the twist. My users are in elementary school. They don't file bug reports. When something's slow or broken, they do one thing... they quit and hand the phone back to go play Roblox. That one constraint changed how I thought about the whole system, and it's the thread running through everything below: the architecture, the stage-machine bug that became a Bedrock throttling storm, and the reconciliation layer that stitches two AI services together. Every snippet here is from the deployed functions.
What Bee Hunter does
A kid photographs a handwritten spelling test, and a few seconds later the app shows which words are spelled correctly, how legible each one is, and a cropped image of each word so you can see what they actually wrote. It runs serverless on AWS, scales to zero, and is cheap enough that a project for two kids doesn't turn into a bill I resent.
The architecture
A photo goes in, word-by-word feedback comes out:
Photo upload
-> S3 (image storage)
-> Textract (OCR: finds WHERE the words are)
-> Claude / Bedrock (reads WHAT the words say + scores them)
-> Reconcile (fuzzy-match the two together)
-> Results (crops + spelling + legibility scores)
The pieces: S3 + CloudFront for hosting and image storage, API Gateway + Lambda for the backend (14 small functions, one job each), Amazon Textract for bounding boxes, Amazon Bedrock (Claude Sonnet 4.5) to read and score the handwriting, and DynamoDB for submissions and results.
The flow: the browser uploads straight to S3 with a presigned URL. That fires an S3 event to beehunter-textract-processor, which runs Textract and writes a record to beehunter-textract-results stamped stage: 'textract_complete'. That write fires a DynamoDB stream to beehunter-claude-analyzer, the brain of the app: it reads the image, calls Bedrock, reconciles the two AI outputs, scores every word, and writes the final record to beehunter-results. The frontend polls GET /status/{id}, which checks results first and falls back to the textract stage for a progress message.
Two facts about that analyzer step set up the best bug in the project. First, it's a Lambda writing to DynamoDB tables that also have streams, and it emits its own claude_starting/claude_analyzing/complete write-backs so the poller can show progress. Every one of those is itself a stream event. Second, DynamoDB Streams deliver at-least-once, not exactly-once, so a record can hit your Lambda more than once and your function has to be idempotent. Hold both thoughts.
Two AI services that don't talk to each other
I'm using two AI services, and each is good at exactly half the job.
Textract nails where the words are. Pixel-perfect boxes, as fractions of the image. Here's a real spelling test one of my kids handed me:
One of my kids' actual spelling tests. "Ruler" gave everybody trouble.
And here's what Textract made of it:
| Word | X | Y | W | H | Confidence |
|---|---|---|---|---|---|
| river | 862 | 400 | 471 | 187 | 99.3% |
| meter | 850 | 581 | 559 | 207 | 100% |
| paper | 859 | 778 | 475 | 264 | 99.7% |
| tiger | 859 | 1255 | 450 | 212 | 83.9% |
| number | 890 | 1580 | 681 | 173 | 99.6% |
| ruler | 1008 | 2057 | 475 | 179 | 34.4% |
| swerve | 811 | 2840 | 613 | 181 | 84.1% |
| germ | 873 | 3187 | 399 | 174 | 99.6% |
Look at the coordinates. Dead-on, every time. Now look at "ruler": 34.4% confidence, because Textract actually read it as "Fuler." There's a particular kind of humbling in watching the app you built squint at your own kid's homework and guess wrong. It can't reliably read a kid's handwriting, and the messier the word, the worse it gets. The long ones a kid mangles a different way every time... "because" coming out becuase one week and becuse the next... are exactly where Textract's confidence craters.
Claude is the opposite. It reads the handwriting and understands intent. It knows the kid meant "ruler." But ask it for pixel coordinates and it tells on itself:
A couple notes:
coordinates are eyeballed from the visual layout, not from an OCR bounding-box pass, so treat them as approximate anchor points.
The two words I'd flag as less certain are "hermit" and "ruler" (the r is rough).
"Approximate anchor points" won't crop an image. So I need both. Claude's read for accurate feedback, Textract's boxes for the crop. The catch is they disagree about the same word:
Textract sees: "Fuler" (34.4% confidence, but exact coordinates)
Claude sees: "ruler" (reads it right, no usable coordinates)
Target word: "ruler"
"Fuler" == "ruler" is False. Naive exact-match drops the word, so you get no crop and no score. I need a reconciliation layer that connects Claude's read to Textract's coordinates even when the strings disagree.
Reconciliation is a three-tier cascade
For each word Claude reports, the analyzer tries three strategies in order and stops at the first hit.
Tier 1: exact match. Claude's word matches a Textract word case-insensitively, and that box isn't claimed yet. Cheap and unambiguous.
Tier 2: hardcoded special cases. Scars from watching real submissions. When one specific misread showed up often enough, I stopped being clever and just hardcoded it. Two are literally in the deployed code:
# "geam" should match "feam" (common g/f OCR error)
if written_word == "geam":
feam_match = next((tw for tw in textract_words
if tw['text'].lower() == 'feam'
and tw['text'] not in used_textract_words), None)
...
# "him self" should match "him" + "self" and combine their boxes
elif written_word == "him self":
...
min_left = min(him_bbox['x'], self_bbox['x'])
max_right = max(him_bbox['x'] + him_bbox['width'], self_bbox['x'] + self_bbox['width'])
...
The "him self" case is the interesting one. Claude reads two Textract tokens as one intended word, so I merge two boxes into one crop with min/max corners. Special-case tiers accumulate, and each one is a signal your general strategy has a blind spot. I keep these because they're cheap and they document real failures. A third or fourth would be pressure to go fix Tier 3 instead.
Tier 3: fuzzy match. Fall back to difflib.SequenceMatcher(...).ratio() between Claude's word and each unclaimed Textract word, keeping the best above a threshold:
FUZZY_MATCH_THRESHOLD = 0.5 # lowered from 0.6 for better word matching
similarity = difflib.SequenceMatcher(None, claude_word.lower(),
tw['text'].lower()).ratio()
if similarity > best_similarity and similarity >= FUZZY_MATCH_THRESHOLD:
best_match, best_similarity = tw, similarity
That 0.5, lowered from 0.6 comment is where the real work hides. SequenceMatcher.ratio() is brutally sensitive to length, so on the three- and four-letter words first graders get, a single bad character drops them under a 0.6 threshold and they vanish. Lowering to 0.5 buys them back; drop much lower and "the" starts matching "he" and stealing the wrong box. The used_words set threads through all three tiers so two of Claude's words can't claim the same box.
The subtle part: I grade Textract, not Claude
The most subtle decision in the app. When I compute the spelling score, I don't score Claude's corrected reading. I score Textract's raw detection:
# Use TEXTRACT detection (actual handwriting), not Claude's correction
actual_written_word = textract_match['text']
letter_accuracy, _ = calculate_letter_accuracy(
actual_written_word, # what the OCR literally saw
match['target_word'], # what it was supposed to be
)
Claude is helpful, and helpful is wrong here. A kid writes becuase and Claude wants to hand you back "because." But the whole point of a spelling app is to catch that the kid wrote it wrong. If I scored Claude's cleaned-up read, every kid gets 100%. So Claude finds and labels the word, and Textract's dumb, literal, character-for-character read grades it. And calculate_letter_accuracy is a real Levenshtein aligner, not a ratio: it classifies every character as a match, substitution, insertion, or deletion, which is what powers "you swapped the 'a' and 'u' in 'because'" instead of just "wrong."
The war story: a stage bug became a throttling storm
The pipeline is event-driven. Textract writes stage: 'textract_complete', the stream triggers the analyzer. Simple. Except the analyzer's guard checked for the wrong stage value:
# BROKEN: Textract writes stage='textract_complete', but the guard
# waits for 'storing_results', which never arrives on the trigger event.
if processing_status == 'textract_complete' and stage == 'storing_results':
# run Claude analysis
The intended trigger never cleanly matched. Meanwhile the analyzer's own status write-backs (claude_starting, claude_analyzing) and user retries kept firing new stream events into the same function. Not one clean run, but a swarm of overlapping invocations all reaching for Bedrock at once.
That's when Bedrock started returning ServiceUnavailableException / "Too many connections." Users got 60-second timeouts, and it would have gotten exponentially worse with more traffic. A one-word string mismatch in an if statement was a latent denial-of-service against my own model endpoint.
The fix has two halves.
Fix the stage machine so there's exactly one trigger. Match the value Textract actually writes, and set the terminal stage to something that isn't a trigger:
# FIXED: match the stage Textract really writes
if processing_status == 'textract_complete' and stage == 'textract_complete':
# run Claude analysis
else:
# our own write-backs (claude_starting/analyzing/complete) land here
return {'statusCode': 200, 'body': 'Event ignored - not ready'}
The final write sets stage: 'complete', which no longer matches the trigger. The self-loop is closed structurally, and my own progress writes fall through to the harmless else.
Make Bedrock calls survive a throttle. Even loop-free, a burst of real uploads can make Bedrock push back, so add exponential backoff with jitter:
max_retries = 4
base_delay = 1 # 1s -> 2s -> 4s -> 8s, plus 0-2s random jitter
for attempt in range(max_retries + 1):
try:
claude_response = bedrock_client.invoke_model(...)
break
except Exception as e:
if "ServiceUnavailableException" in str(e) or "throttled" in str(e).lower():
if attempt < max_retries:
time.sleep(base_delay * (2 ** attempt) + random.uniform(0, 2))
continue
raise e
The jitter is the part people skip. Without it, ten throttled invocations back off for exactly 2**attempt seconds and retry in lockstep, restampeding the endpoint. random.uniform(0, 2) smears them across a window so they don't re-collide.
The lesson worth keeping: the stage typo didn't just cause wrong behavior, it manufactured concurrency, and the concurrency is what took Bedrock down.
There's a UX angle here too. Claude's analysis takes ~25 seconds, and a seven-year-old's patience budget is about 20, so latency is a reliability metric, not just a performance one. I learned that number the hard way, watching my kid take the photo, wait, sigh, and set the phone down to go do literally anything else before the score came back. If your feedback loop is slower than your user's attention span, the system has failed that user. I couldn't make Claude faster, so I made the wait survivable with those same update_processing_status() write-backs and a GET /status/{id} poll that turns them into live progress. That's the real SLO for this app: not "99.9% under 500ms," but "results appear before the kid walks away." The analyzer already logs a per-step timing breakdown, so the honest next move is to emit those as CloudWatch metrics and alarm on p90 end-to-end, not the average that hides the one kid who waited 40 seconds behind the nine who waited 10.
What I'd add before letting strangers in
The fix above is what's deployed, and it's right for two kids. But a 300-level reader will see what's missing:
-
A conditional write for real idempotency. The stage guard stops the loop, but at-least-once delivery means the same event can still arrive twice. A
ConditionExpression(attribute_not_exists(id)) on the results write makes a duplicate a no-op at the database, so I never pay for a second Bedrock call on a dupe. Today I rely on the stage flag, which is code-level, not storage-level. -
A dead-letter queue on the stream mapping. A poison record (corrupt image, unparseable response) makes a stream Lambda retry the whole batch until records expire, up to 24 hours, blocking the shard and re-billing Bedrock every spin.
BisectBatchOnFunctionError, aMaximumRetryAttemptscap, and anOnFailuredestination would isolate it. -
Infrastructure-as-code for the stream mappings, retry config, and DLQ. Right now I redeploy with
aws lambda update-function-codeand a prayer. There's literally a.lambda.backup-*folder from the day I fixed the loop. Fine for a personal app, but those retry settings are exactly what you can't afford to configure by hand and forget.
What I took away
-
A logic bug and a capacity failure can be the same incident. A one-word stage mismatch manufactured concurrency, and the concurrency is what threw
ServiceUnavailableException. Guard the trigger edge precisely, and assume every downstream call can be throttled. -
Backoff without jitter just reschedules the stampede.
random.uniform(0, 2)on top of exponential delay is what keeps retries from re-colliding. - Use the "wrong" service on purpose. Claude to read, Textract to grade, so the helpful model can't auto-correct away the mistakes the app exists to catch.
The best engineering I've done on this app didn't happen at a desk. It happened at the kitchen table, with my kids handing me the phone and quitting the second it got slow. The most honest QA team I've ever had can't spell "because"... but they'll tell you when your app is too slow.
Happy building.
Bee Hunter runs on AWS: S3, CloudFront, API Gateway, Lambda, DynamoDB, Amazon Textract, and Amazon Bedrock (Claude Sonnet 4.5).


Top comments (1)
The part I keep turning over: the bug wasn't a crash, it was a scheduler. One wrong string in the guard meant the branch never matched, so the retries and the status write-backs kept re-entering the same function — a logic bug that manufactured its own traffic and then took the model endpoint down. A guard that returns 200 on "not my trigger" is the right shape for that loop, though it also means those events now exit silently. Your DLQ note is the real follow-up: without it, a poison event is invisible until someone pays for a Bedrock call that shouldn't have happened.