Build a Height Verification Pipeline
Build a resilient local job pipeline with idempotency, retries, and a DLQ.
Introduction
30 Second Summary
When a verification screen takes too long, confidence turns into frustration. Duplicate requests create a quieter risk because the same decision can happen twice.
In this project, you will build HeightCheck, a Tinder-inspired local system-design lab that estimates height through controlled-reference measurement. You will evolve its blocking check into a resilient asynchronous job pipeline.
What You'll Build
Your finished demo turns a height claim into a live job audit that proves the pipeline stays correct under duplicate delivery.
By the end of this project, you'll have:
- A controlled-reference height signal that shows the estimate beside its demo decision.
- A synchronous versus asynchronous comparison where the blocking request waits while the queued job returns immediately for job status polling.
- An interview-ready resilience story backed by audit histories. At-least-once delivery explains why idempotency keys matter. Guarded state transitions keep terminal outcomes stable. Bounded retries end in a dead-letter queue on your AWS architecture map.
- Secret Mission: Stress the finished pipeline with five duplicate-delivery jobs plus one forced-failure job. Use their audit histories to prove the three correctness invariants.
Are there any prerequisites?
Comfort with JavaScript, HTTP APIs, and basic distributed-system concepts helps you focus on the design trade-offs.
The lab uses a Mac with Visual Studio Code, Node.js, Safari, and macOS Terminal already available.
Before We Start
Before any hands-on work, lock in the goal for your local controlled-reference height-verification demo. Duplicate queue delivery must leave the job's final outcome unchanged without repeating its verification side effect.
Set Up the Local Verification Lab
Your system-design goal needs a fast feedback loop before queues or retries enter the picture. A local Node.js server gives you that loop without cloud setup or third-party packages.
In this step, you'll create the smallest runnable HeightCheck interface in Visual Studio Code. You'll serve it through HTTP so later steps can focus on asynchronous correctness.
In this step, get ready to:
- Verify Node.js 24.20.0 LTS on your Mac.
- Create the HeightCheck workspace files.
- Run the local server in Safari and confirm the starter interface loads.
Verify the Node.js runtime
The starter server uses Node.js 24.20.0 LTS. Checking the installed version now keeps the local behavior consistent with the project code.
- Press Cmd+Space to open macOS search.
- Type Terminal.
- Press Enter to open macOS Terminal.
- Check your installed Node.js version by running this command:
node -v
✔️ I see v24.20.0
Your Node.js runtime matches the project. The local server can use the expected built-in modules.
ⓧ I see an older version
Your current runtime needs an upgrade before you build the server.
- Open the official Node.js download page in your browser.
- Download Node.js 24.20.0 LTS.
- Complete the Node.js installation.
- Return to macOS Terminal.
- Check the upgraded version by running this command:
node -v
You should see v24.20.0 in the output.
ⓧ I see command not found
Node.js is unavailable from your terminal. Installing the pinned LTS release adds the runtime used by this project.
- Open the official Node.js download page in your browser.
- Download Node.js 24.20.0 LTS.
- Complete the Node.js installation.
- Return to macOS Terminal.
- Check the installed version by running this command:
node -v
You should see v24.20.0 in the output.
Still seeing the previous version?
Close macOS Terminal after the installation. Open a new Terminal window so its command path includes the new runtime.
If the version still does not match, help me diagnose my Node.js version.
Create the HeightCheck workspace
The workspace separates browser files from the server entry point. This structure lets the server expose the interface through three static routes.
- Press Cmd+Space to open macOS search.
- Type Visual Studio Code.
- Press Enter to open Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder....
- Create a folder on your Desktop with the New Folder control.
- Enter height-check-pipeline as the folder name.
- Confirm the folder selection.
Your Workspace Is Ready
Visual Studio Code now treats height-check-pipeline as one workspace. Every project file you add appears in the Explorer sidebar.
- Create public with the Explorer's New Folder control.
- Create server.js in height-check-pipeline with the Explorer's New File control.
- Create architecture.md in height-check-pipeline with the Explorer's New File control.
- Create index.html inside public with the Explorer's New File control.
- Create app.js inside public with the Explorer's New File control.
- Create styles.css inside public with the Explorer's New File control.
The Explorer should now show server.js and architecture.md beside the expanded public folder. Inside public, you should see the three browser files.
Build and run the starter interface
The server has one responsibility in this step. It maps incoming browser paths to files inside public.
- Select server.js in the Explorer.
- Add the static-file loader by pasting this code:
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const PORT = 3000;
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
What Does This Code Do?
- The built-in modules create the server. They also read files without third-party packages.
- The files object maps each public path to its file and content type.
- The serveStatic() function returns false when no static route matches.
- Save server.js.
- Confirm the editor shows serveStatic() beneath the PORT constant.
Seeing Red Underlines?
Check each module name against the snippet. A missing quote or bracket prevents Visual Studio Code from parsing the file.
For another set of eyes, help me compare this static-file loader.
- Add the request handler beneath serveStatic() by pasting this code:
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
});
How Does the Server Respond?
- The request handler passes each browser path to serveStatic().
- Unknown paths receive a JSON response with status 404.
- The server listens on port 3000.
- Save server.js.
- Open the integrated terminal from Visual Studio Code's terminal panel.
- Predict whether the server can start while the static files are empty.
- Start the local server by running this command:
node server.js
You should see HeightCheck is running at http://localhost:3000. The terminal stays occupied while the server listens for requests.
Server Did Not Start?
A syntax error usually points to the first malformed line in server.js. Compare that line with the snippets above.
If the terminal reports another server using the port, stop that process before trying again. You can also ask for help with the startup error.
✔️ Awesome, I've got everything!
Your static server is running. Keep the integrated terminal active while you build the page.
ⓧ I'd like to double check the full code
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const PORT = 3000;
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
});
The browser page starts with valid document structure. Each following chunk adds one visible part of the HeightCheck interface.
- Select public/index.html in the Explorer.
- Add the page shell by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HeightCheck System Design Lab</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="layout">
<section class="panel">
<p class="eyebrow">Tinder-inspired system design lab</p>
<h1>HeightCheck</h1>
<p>
Compare a claimed height with measurements from a controlled photo containing
a known-size reference on the same plane. This lab is not affiliated with Tinder.
</p>
<form id="verification-form">
</form>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
What Does the Page Shell Provide?
The document links the stylesheet and browser script through the server routes. The first panel introduces the controlled-reference measurement scenario.
- Save public/index.html.
- Press Cmd+Space to open macOS search.
- Type Safari.
- Press Enter to open Safari.
- Enter http://localhost:3000 in the address bar.
You should see the HeightCheck heading with the controlled-reference explanation. That page proves the server can read public/index.html.
Page Not Loading?
Return to the integrated terminal and confirm the server process is still running. Check that index.html sits inside public.
If Safari shows an error, help me trace the static route.
- Insert the controlled-reference fields inside verification-form by pasting this code:
<div class="grid">
<label>
Claimed height (cm)
<input id="claimed-height" type="number" value="180" min="1" required>
</label>
<label>
Person height in image (pixels)
<input id="person-pixels" type="number" value="900" min="1" required>
</label>
<label>
Reference height in image (pixels)
<input id="reference-pixels" type="number" value="500" min="1" required>
</label>
<label>
Reference real height (cm)
<input id="reference-height" type="number" value="100" min="1" required>
</label>
</div>
Why These Four Inputs?
The claimed height provides the value being checked. The two pixel measurements and known reference height provide the constrained scale used by the later calculation.
- Save public/index.html.
- Refresh Safari.
You should see four numeric fields with default values. The fields capture a claim plus measurements from a controlled image.
Missing an Input?
Confirm the entire grid block sits between the opening and closing verification-form tags.
You can ask for help checking the form structure.
- Add the delivery controls beneath the grid block by pasting this code:
<label class="check">
<input id="duplicate-delivery" type="checkbox" checked>
Deliver the same message twice
</label>
<label class="check">
<input id="force-failure" type="checkbox">
Simulate verifier outage
</label>
<div class="actions">
<button id="sync-button" type="button" class="secondary">Run synchronously</button>
<button type="submit">Submit async job</button>
</div>
</form>
<div id="summary" class="summary" aria-live="polite">Ready.</div>
What Will These Controls Test?
The two buttons expose the synchronous and asynchronous paths built later. The checkboxes create the duplicate-delivery and failure scenarios used to test correctness.
- Remove the earlier empty closing form tag.
- Save public/index.html.
- Refresh Safari.
You should see two checkboxes and two action buttons beneath the fields. The summary area should display Ready..
Controls Outside the Form?
Keep one closing form tag after the actions. A duplicate closing tag can place the summary or buttons outside the intended structure.
If the nesting looks wrong, help me repair the form tags.
- Add the Job Audit panel before the closing main tag by pasting this code:
<section class="panel">
<p class="eyebrow">Job status polling</p>
<h2>Job Audit</h2>
<dl class="facts">
<div><dt>Job ID</dt><dd id="job-id">None</dd></div>
<div><dt>Idempotency key</dt><dd id="idempotency-key">None</dd></div>
<div><dt>Status</dt><dd id="job-status">None</dd></div>
<div><dt>Attempts</dt><dd id="job-attempts">0</dd></div>
<div><dt>Process count</dt><dd id="process-count">0</dd></div>
<div><dt>DLQ count</dt><dd id="dlq-count">0</dd></div>
</dl>
<div id="result" class="result">No result yet.</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Time</th><th>Event</th><th>Note</th></tr>
</thead>
<tbody id="history-body">
<tr><td colspan="3">No job submitted.</td></tr>
</tbody>
</table>
</div>
</section>
What Will the Audit Panel Reveal?
The facts show job state and processing counters. The history table gives each queue event a visible audit trail.
- Save public/index.html.
- Refresh Safari.
You should see the Job Audit heading with empty job facts and a history row that says No job submitted..
Audit Panel Missing?
Confirm the second panel section sits inside main. It should appear after the section containing the form.
For help with the document nesting, check my two-panel HTML.
✔️ Awesome, I've got everything!
Your complete starter page now contains the form and empty audit panel.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HeightCheck System Design Lab</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="layout">
<section class="panel">
<p class="eyebrow">Tinder-inspired system design lab</p>
<h1>HeightCheck</h1>
<p>
Compare a claimed height with measurements from a controlled photo containing
a known-size reference on the same plane. This lab is not affiliated with Tinder.
</p>
<form id="verification-form">
<div class="grid">
<label>
Claimed height (cm)
<input id="claimed-height" type="number" value="180" min="1" required>
</label>
<label>
Person height in image (pixels)
<input id="person-pixels" type="number" value="900" min="1" required>
</label>
<label>
Reference height in image (pixels)
<input id="reference-pixels" type="number" value="500" min="1" required>
</label>
<label>
Reference real height (cm)
<input id="reference-height" type="number" value="100" min="1" required>
</label>
</div>
<label class="check">
<input id="duplicate-delivery" type="checkbox" checked>
Deliver the same message twice
</label>
<label class="check">
<input id="force-failure" type="checkbox">
Simulate verifier outage
</label>
<div class="actions">
<button id="sync-button" type="button" class="secondary">Run synchronously</button>
<button type="submit">Submit async job</button>
</div>
</form>
<div id="summary" class="summary" aria-live="polite">Ready.</div>
</section>
<section class="panel">
<p class="eyebrow">Job status polling</p>
<h2>Job Audit</h2>
<dl class="facts">
<div><dt>Job ID</dt><dd id="job-id">None</dd></div>
<div><dt>Idempotency key</dt><dd id="idempotency-key">None</dd></div>
<div><dt>Status</dt><dd id="job-status">None</dd></div>
<div><dt>Attempts</dt><dd id="job-attempts">0</dd></div>
<div><dt>Process count</dt><dd id="process-count">0</dd></div>
<div><dt>DLQ count</dt><dd id="dlq-count">0</dd></div>
</dl>
<div id="result" class="result">No result yet.</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Time</th><th>Event</th><th>Note</th></tr>
</thead>
<tbody id="history-body">
<tr><td colspan="3">No job submitted.</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
- Select public/app.js in the Explorer.
- Connect the browser script to the starter interface by pasting this code:
const form = document.querySelector('#verification-form');
const syncButton = document.querySelector('#sync-button');
const summary = document.querySelector('#summary');
const historyBody = document.querySelector('#history-body');
What Does the Browser Script Select?
These references connect JavaScript to the form and synchronous button. They also identify the summary area and audit-history table body used in later steps.
- Save public/app.js.
- Refresh Safari.
The page should remain loaded with Ready. in the summary. The script now resolves all four starter elements from the page.
Page Changed After Adding JavaScript?
Check each selector against the matching ID in public/index.html. Selector spelling includes the leading #.
You can ask for help comparing the selectors.
✔️ Awesome, I've got everything!
Your starter browser script selects the interface elements needed later.
ⓧ I'd like to double check the full code
const form = document.querySelector('#verification-form');
const syncButton = document.querySelector('#sync-button');
const summary = document.querySelector('#summary');
const historyBody = document.querySelector('#history-body');
- Select architecture.md in the Explorer.
- Define the scope of the system-design lab by pasting this content:
# HeightCheck Architecture Notes
## Scope
This is a Tinder-inspired learning project. It is not affiliated with Tinder and does not reproduce Tinder's undocumented internal architecture. The verification result is a confidence signal, not proof of height.
Why Define the Scope Now?
The note keeps the demo's trust signal within clear limits. It also separates the learning project from Tinder's product and undocumented architecture.
- Save architecture.md.
- Confirm the file begins with HeightCheck Architecture Notes.
Scope Note Looks Different?
Compare the heading levels and paragraph text with the snippet. Markdown headings need the same number of # characters.
If the formatting is unclear, help me check this Markdown file.
✔️ Awesome, I've got everything!
Your architecture notes now state the lab's scope and trust limitation.
ⓧ I'd like to double check the full code
# HeightCheck Architecture Notes
## Scope
This is a Tinder-inspired learning project. It is not affiliated with Tinder and does not reproduce Tinder's undocumented internal architecture. The verification result is a confidence signal, not proof of height.
The page structure is complete. The stylesheet now turns that structure into the two-panel dark interface used throughout the lab.
- Select public/styles.css in the Explorer.
- Add the page foundation by pasting this code:
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #11131a;
color: #f7f7fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #3c1f43, #11131a 48%);
}
.layout {
width: min(1180px, calc(100% - 32px));
margin: 40px auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
What Does the Foundation Style?
The root rules establish the dark color scheme and font stack. The layout places the two main sections in equal columns.
- Save public/styles.css.
- Refresh Safari.
You should see a dark gradient background. The form and Job Audit content should sit in two columns.
Styles Not Appearing?
Confirm the file is named styles.css inside public. Check that public/index.html links to /styles.css.
You can ask for help tracing the stylesheet route.
- Add the panel and heading styles beneath .layout by pasting this code:
.panel {
background: rgba(24, 27, 37, 0.94);
border: 1px solid #34394a;
border-radius: 18px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.eyebrow {
margin: 0;
color: #ff6b91;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2 {
margin: 8px 0 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 24px 0 16px;
}
How Do the Panels Take Shape?
Each panel gains a border and rounded background. The input grid arranges the four measurements in two columns.
- Save public/styles.css.
- Refresh Safari.
You should see two rounded panels. The four numeric fields should form a two-column grid.
Panels Still Look Flat?
Check that both sections use class="panel". Confirm the selector begins with a full stop in the stylesheet.
For help finding the mismatch, compare my panel class and selector.
- Add the form-control layout beneath .grid by pasting this code:
label {
display: grid;
gap: 7px;
color: #d8dbea;
font-size: 0.9rem;
}
input[type="number"] {
width: 100%;
padding: 11px 12px;
border: 1px solid #4a5064;
border-radius: 9px;
background: #10121a;
color: #fff;
}
.check {
display: flex;
align-items: center;
gap: 9px;
margin: 10px 0;
}
.actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
What Changes in the Form?
The labels create consistent spacing around each field. The checkbox and action rows use horizontal alignment.
- Save public/styles.css.
- Refresh Safari.
You should see dark rounded number inputs. The checkboxes and buttons should align in horizontal rows.
Inputs Still Use Default Styling?
Confirm each measurement field uses type="number". The CSS selector only targets inputs with that type.
You can ask for help checking the input selector.
- Add the button and result styles beneath .actions by pasting this code:
button {
border: 0;
border-radius: 10px;
padding: 11px 16px;
background: #ff477e;
color: white;
font-weight: 800;
cursor: pointer;
}
button.secondary {
background: #34394a;
}
.summary,
.result {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
background: #10121a;
border: 1px solid #34394a;
overflow-wrap: anywhere;
}
.facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
How Are Actions and Results Separated?
The primary action uses the pink accent. Summary and result areas gain bordered containers so status changes remain easy to spot.
- Save public/styles.css.
- Refresh Safari.
You should see a pink asynchronous button and a darker synchronous button. The summary and result text should sit inside bordered boxes.
Buttons Look Identical?
Confirm the synchronous button has class="secondary". The second button uses the base button style without that class.
If both buttons still match, help me check the button selectors.
- Add the audit-fact styles beneath .facts by pasting this code:
.facts div {
padding: 10px;
background: #10121a;
border-radius: 9px;
}
.facts dt {
color: #9298ad;
font-size: 0.75rem;
text-transform: uppercase;
}
.facts dd {
margin: 5px 0 0;
overflow-wrap: anywhere;
}
.table-wrap {
margin-top: 18px;
overflow-x: auto;
}
Why Separate the Audit Facts?
Each job fact gets its own dark tile. Long job identifiers can wrap without pushing the panel beyond its width.
- Save public/styles.css.
- Refresh Safari.
You should see six compact fact tiles in the Job Audit panel. Their labels should appear in small uppercase text.
Facts Not Forming Tiles?
Confirm the job facts remain inside the element with class="facts". Each fact should use its own nested div.
You can ask for help checking the facts structure.
- Finish the audit table and responsive layout by pasting this code beneath .table-wrap.
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
padding: 10px;
border-bottom: 1px solid #34394a;
text-align: left;
vertical-align: top;
}
@media (max-width: 820px) {
.layout,
.grid {
grid-template-columns: 1fr;
}
}
How Does the Layout Adapt?
The table keeps audit events aligned in rows. The media rule switches the panels and input grid to one column on narrower screens.
- Save public/styles.css.
- Refresh Safari.
You should see a bordered audit-history table. Narrowing Safari below 820px should stack the panels into one column.
Table Columns Look Uneven?
Confirm the th and td selectors share one rule block. Check that the table remains inside table-wrap.
For help with the final styles, compare my audit table CSS.
✔️ Awesome, I've got everything!
Your full starter stylesheet now renders the dark two-panel interface.
ⓧ I'd like to double check the full code
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #11131a;
color: #f7f7fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #3c1f43, #11131a 48%);
}
.layout {
width: min(1180px, calc(100% - 32px));
margin: 40px auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.panel {
background: rgba(24, 27, 37, 0.94);
border: 1px solid #34394a;
border-radius: 18px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.eyebrow {
margin: 0;
color: #ff6b91;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2 {
margin: 8px 0 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 24px 0 16px;
}
label {
display: grid;
gap: 7px;
color: #d8dbea;
font-size: 0.9rem;
}
input[type="number"] {
width: 100%;
padding: 11px 12px;
border: 1px solid #4a5064;
border-radius: 9px;
background: #10121a;
color: #fff;
}
.check {
display: flex;
align-items: center;
gap: 9px;
margin: 10px 0;
}
.actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
button {
border: 0;
border-radius: 10px;
padding: 11px 16px;
background: #ff477e;
color: white;
font-weight: 800;
cursor: pointer;
}
button.secondary {
background: #34394a;
}
.summary,
.result {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
background: #10121a;
border: 1px solid #34394a;
overflow-wrap: anywhere;
}
.facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.facts div {
padding: 10px;
background: #10121a;
border-radius: 9px;
}
.facts dt {
color: #9298ad;
font-size: 0.75rem;
text-transform: uppercase;
}
.facts dd {
margin: 5px 0 0;
overflow-wrap: anywhere;
}
.table-wrap {
margin-top: 18px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
padding: 10px;
border-bottom: 1px solid #34394a;
text-align: left;
vertical-align: top;
}
@media (max-width: 820px) {
.layout,
.grid {
grid-template-columns: 1fr;
}
}
- Predict which two major areas should be visible after the final refresh.
- Refresh http://localhost:3000 in Safari.
You should see the HeightCheck form in the left panel. You should also see an empty Job Audit panel with No job submitted. in its history table.
That is the local feedback loop ready: your server now delivers the interface that every later verification scenario builds on.
Your local lab is running with no cloud resources or third-party packages. Next up, you'll make the synchronous request wait for a controlled-reference result.
Build Synchronous Verification
Your HeightCheck interface is running locally. Right now, the form cannot calculate a result or reveal how slow backend work affects the browser.
This step adds a synchronous API to your Node.js server. The browser stays tied to each request while the server validates the measurements and calculates the result.
In this step, get ready to:
- Validate the four controlled-reference measurements.
- Calculate a demo height estimate with the project policy.
- Measure the delay of synchronous verification in the browser.
Validate and calculate the result
A controlled-reference measurement compares the person's pixel height with an object whose real height is known. The project uses this constrained ratio to create a teaching signal.
The server first needs to read the request body. It also needs to reject measurements that are missing, non-numeric, infinite, or zero.
- In Visual Studio Code, select server.js from the Explorer sidebar.
- Stop the running server from the integrated terminal.
- Add readJson above serveStatic by copying this code:
function readJson(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
request.destroy(new Error('Request body is too large'));
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
request.on('error', reject);
});
}
What does this code do?
- The function collects incoming request chunks into one body.
- The size check stops an unexpectedly large request before more data is accepted.
- The parser converts valid JSON into an object for the verification route.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this command do?
This command executes the updated server entry file. A successful startup confirms that the new request reader has valid syntax.
You should see HeightCheck is running at http://localhost:3000 in the integrated terminal.
Server no longer starts?
Compare the closing braces around request.on('end', ...) with the snippet. One missing brace prevents the server from starting.
Ask for help with the parser if the error remains. Help me debug the readJson function.
- Stop the running server from the integrated terminal.
- Add validateInput below readJson by copying this code:
function validateInput(payload) {
const fields = [
'claimedHeightCm',
'personPixels',
'referencePixels',
'referenceHeightCm',
];
for (const field of fields) {
if (typeof payload[field] !== 'number' || !Number.isFinite(payload[field]) || payload[field] <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
}
return {
claimedHeightCm: payload.claimedHeightCm,
personPixels: payload.personPixels,
referencePixels: payload.referencePixels,
referenceHeightCm: payload.referenceHeightCm,
duplicateDelivery: Boolean(payload.duplicateDelivery),
forceFailure: Boolean(payload.forceFailure),
};
}
How does validation protect the calculation?
- The fields array identifies the four measurements required by the formula.
- The loop accepts only positive finite numbers.
- The returned object normalizes the two checkbox values into booleans for later pipeline steps.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this check prove?
A successful restart proves that the validation function parses correctly. The API route uses this function in the next substep.
You should see the HeightCheck startup address again.
Seeing a validation syntax error?
Check the closing bracket after 'referenceHeightCm'. Also confirm that the returned object ends with a closing brace.
Use targeted help if the terminal points to a different line. Help me debug validateInput.
- Stop the running server from the integrated terminal.
- Add calculateVerification below validateInput by copying this code:
function calculateVerification(input) {
const estimatedHeightCm = Number(
((input.personPixels / input.referencePixels) * input.referenceHeightCm).toFixed(1),
);
const differenceCm = Number(Math.abs(estimatedHeightCm - input.claimedHeightCm).toFixed(1));
let status = 'rejected';
if (differenceCm <= 2) status = 'verified';
else if (differenceCm <= 5) status = 'manual_review';
return {
estimatedHeightCm,
differenceCm,
status,
disclaimer: 'Demo confidence signal only. This is not proof of height.',
};
}
How is the signal calculated?
- The pixel ratio scales the known reference height into an estimated person height.
- The estimate and claimed-height difference are rounded to one decimal place.
- A difference of at most 2 cm produces verified.
- A difference of at most 5 cm produces manual_review. Larger differences produce rejected.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this restart confirm?
The server now loads the complete calculation policy. The successful startup confirms that every calculation branch has valid syntax.
You should see the local HeightCheck address in the terminal again.
Calculation code blocking startup?
Check the parentheses around toFixed(1). Confirm that every returned property matches the snippet exactly.
Ask for a focused comparison if the server still stops. Help me debug calculateVerification.
Create the synchronous API route
A synchronous request keeps the browser waiting until the server completes every part of the verification. The project uses a fixed delay to make that coupling visible.
- Stop the running server from the integrated terminal.
- Add PROCESSING_DELAY_MS below PORT.
- Add sleep and sendJson above readJson by using this code:
const PROCESSING_DELAY_MS = 1200;
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
Why add these helpers?
- The PROCESSING_DELAY_MS constant gives every synchronous verification the same simulated processing time.
- The sleep function pauses the route through a promise.
- The sendJson function applies a JSON response header before completing the response.
- Save server.js.
- Restart the server by running this command:
node server.js
What should the terminal confirm?
The startup message confirms that the delay and response helpers load successfully. The server is ready for the new route.
You should see the local HeightCheck address again.
Server exits after adding the helpers?
Confirm that PROCESSING_DELAY_MS appears once below PORT. Check the braces around both helper functions.
Get help with the exact failing line if needed. Help me debug the delay helpers.
- Stop the running server from the integrated terminal.
- Find the static-file condition inside the http.createServer callback.
- Add the synchronous route immediately below that condition by copying this code:
if (request.method === 'POST' && url.pathname === '/api/verify-sync') {
const input = validateInput(await readJson(request));
await sleep(PROCESSING_DELAY_MS);
sendJson(response, 200, calculateVerification(input));
return;
}
What happens during the request?
- The condition matches a POST request sent to /api/verify-sync.
- The route reads and validates the submitted measurements before processing them.
- The route waits for PROCESSING_DELAY_MS before returning the calculated result.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this startup prove?
The server has loaded the synchronous route without a syntax error. It continues serving the interface while accepting verification requests.
You should see HeightCheck is running at http://localhost:3000 in the terminal.
Route preventing startup?
Make sure the route sits inside the try block of the server callback. Confirm that it appears before the final route-not-found response.
Ask for help if the reported line remains unclear. Help me place the synchronous route.
Use the second tab if you want to compare your cumulative server with the expected version.
✔️ My server is running
Your server now serves the existing interface and accepts synchronous verification requests.
ⓧ I'd like to double check the full code
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const PORT = 3000;
const PROCESSING_DELAY_MS = 1200;
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
request.destroy(new Error('Request body is too large'));
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
request.on('error', reject);
});
}
function validateInput(payload) {
const fields = [
'claimedHeightCm',
'personPixels',
'referencePixels',
'referenceHeightCm',
];
for (const field of fields) {
if (typeof payload[field] !== 'number' || !Number.isFinite(payload[field]) || payload[field] <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
}
return {
claimedHeightCm: payload.claimedHeightCm,
personPixels: payload.personPixels,
referencePixels: payload.referencePixels,
referenceHeightCm: payload.referenceHeightCm,
duplicateDelivery: Boolean(payload.duplicateDelivery),
forceFailure: Boolean(payload.forceFailure),
};
}
function calculateVerification(input) {
const estimatedHeightCm = Number(
((input.personPixels / input.referencePixels) * input.referenceHeightCm).toFixed(1),
);
const differenceCm = Number(Math.abs(estimatedHeightCm - input.claimedHeightCm).toFixed(1));
let status = 'rejected';
if (differenceCm <= 2) status = 'verified';
else if (differenceCm <= 5) status = 'manual_review';
return {
estimatedHeightCm,
differenceCm,
status,
disclaimer: 'Demo confidence signal only. This is not proof of height.',
};
}
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
if (request.method === 'POST' && url.pathname === '/api/verify-sync') {
const input = validateInput(await readJson(request));
await sleep(PROCESSING_DELAY_MS);
sendJson(response, 200, calculateVerification(input));
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
});
How should you use this reference?
Compare the order of your imports, helpers, static-file handler, and synchronous route with this cumulative file. Keep the server running after your comparison.
Measure the browser wait
The browser needs to collect the form values and send them to the synchronous route. It also records the elapsed time so the waiting cost becomes visible.
- Select public/app.js from the Explorer sidebar.
- Add inputPayload below the four existing element selections by copying this code:
function inputPayload() {
return {
claimedHeightCm: Number(document.querySelector('#claimed-height').value),
personPixels: Number(document.querySelector('#person-pixels').value),
referencePixels: Number(document.querySelector('#reference-pixels').value),
referenceHeightCm: Number(document.querySelector('#reference-height').value),
duplicateDelivery: document.querySelector('#duplicate-delivery').checked,
forceFailure: document.querySelector('#force-failure').checked,
};
}
What does the payload contain?
The function reads the four controlled-reference inputs as numbers. It also captures the two checkbox states for later pipeline behavior.
- Save public/app.js.
- Refresh the HeightCheck page in Safari.
You should still see the four default values of 180, 900, 500, and 100 in the form.
Form missing after the refresh?
Check the closing brace of inputPayload. Confirm that each selector matches the snippet exactly.
Use focused help if the browser script still fails. Help me debug inputPayload.
- Add requestJson below inputPayload by copying this code:
async function requestJson(pathname, options = {}) {
const response = await fetch(pathname, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Request failed');
return payload;
}
How does the request helper work?
The helper sends a request with a JSON content header. It returns the parsed response or throws the server's error message.
- Save public/app.js.
- Refresh the HeightCheck page in Safari.
You should see the form and the empty Job Audit panel load without a browser error.
Page script failing after the helper?
Check the braces around the options object passed to fetch. Confirm that await response.json() remains inside requestJson.
Ask for help if the page still fails to load correctly. Help me debug requestJson.
- Add the synchronous click handler below requestJson by copying this code:
syncButton.addEventListener('click', async () => {
const startedAt = performance.now();
summary.textContent = 'Synchronous request is waiting for the verifier...';
try {
const result = await requestJson('/api/verify-sync', {
method: 'POST',
body: JSON.stringify(inputPayload()),
});
const duration = Math.round(performance.now() - startedAt);
summary.textContent = `Synchronous result: ${result.status}, estimated ${result.estimatedHeightCm} cm, ${duration} ms round trip.`;
} catch (error) {
summary.textContent = error.message;
}
});
How is the wait measured?
- The handler records the browser time before sending the request.
- The summary announces that the synchronous request is waiting.
- The final duration measures the complete browser-to-server round trip.
- Save public/app.js.
- Refresh the HeightCheck page in Safari.
- Predict whether the browser responds instantly or waits for the verifier.
- Leave the four numeric inputs at their default values.
- Click Run synchronously.
The summary first shows that the request is waiting. After about 1.2 seconds, you should see an estimate of 180 cm, a verified result, and the measured round-trip duration.
Synchronous result not appearing?
Confirm that the terminal still shows the running server. Check that the handler sends its request to /api/verify-sync.
If the summary shows a validation error, restore all four default values before testing again. Help me debug the synchronous request.
That pause is the lesson: the browser receives no verification result until the backend finishes its work.
Use the second tab if you want to compare your cumulative browser script with the expected version.
✔️ I see the verified result
Your browser now measures the complete synchronous round trip and displays the controlled-reference result.
ⓧ I'd like to double check the full code
const form = document.querySelector('#verification-form');
const syncButton = document.querySelector('#sync-button');
const summary = document.querySelector('#summary');
const historyBody = document.querySelector('#history-body');
function inputPayload() {
return {
claimedHeightCm: Number(document.querySelector('#claimed-height').value),
personPixels: Number(document.querySelector('#person-pixels').value),
referencePixels: Number(document.querySelector('#reference-pixels').value),
referenceHeightCm: Number(document.querySelector('#reference-height').value),
duplicateDelivery: document.querySelector('#duplicate-delivery').checked,
forceFailure: document.querySelector('#force-failure').checked,
};
}
async function requestJson(pathname, options = {}) {
const response = await fetch(pathname, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Request failed');
return payload;
}
syncButton.addEventListener('click', async () => {
const startedAt = performance.now();
summary.textContent = 'Synchronous request is waiting for the verifier...';
try {
const result = await requestJson('/api/verify-sync', {
method: 'POST',
body: JSON.stringify(inputPayload()),
});
const duration = Math.round(performance.now() - startedAt);
summary.textContent = `Synchronous result: ${result.status}, estimated ${result.estimatedHeightCm} cm, ${duration} ms round trip.`;
} catch (error) {
summary.textContent = error.message;
}
});
How should you use this reference?
Compare the selector declarations and function order with your browser script. The synchronous handler should be the final construct in the file at this stage.
You have made the blocking cost visible in the browser. Next, you will queue verification work so the request can return before processing finishes.
Queue Jobs and Expose Duplicates
Your synchronous check proves the measurement flow works. However, the browser stays tied to the Node.js server until verification finishes.
This step turns that blocking flow into an asynchronous API with status polling. You will also test how repeated delivery affects an unprotected worker.
In this step, get ready to:
- Create an in-memory job queue with immediate job acceptance.
- Poll each job until it reaches a terminal status.
- Trigger duplicate delivery to reveal repeated processing.
Create the in-memory job queue
An asynchronous request returns a job identifier before the slow work finishes. The browser can use that identifier to ask for the latest job state.
Why return a job identifier?
The server needs a durable way to connect later status requests with the original work. This lab uses a UUID for the job plus a separate idempotency key for recognizing repeated logical work.
- Return to server.js in Visual Studio Code.
- Add the UUID import below the existing path import.
- Add the asynchronous configuration plus in-memory state below PROCESSING_DELAY_MS by copying this code:
const { randomUUID } = require('node:crypto');
const IDEMPOTENCY_ENABLED = false;
const jobs = new Map();
const queue = [];
let workerRunning = false;
What does this state track?
- The jobs map stores each job under its generated identifier.
- The queue array holds messages waiting for the worker.
- The workerRunning flag prevents two drain loops from competing for the same messages.
- The IDEMPOTENCY_ENABLED value keeps protection disabled for this experiment.
- Add the status helpers below calculateVerification by copying this code:
function recordStatusUnchecked(job, nextStatus, note) {
job.status = nextStatus;
job.history.push({ status: nextStatus, at: timestamp(), note });
}
function moveJob(job, nextStatus, note) {
if (IDEMPOTENCY_ENABLED) transitionJob(job, nextStatus, note);
else recordStatusUnchecked(job, nextStatus, note);
}
function publicJob(job) {
return {
...job,
queueDepth: queue.length,
};
}
How does unchecked movement work?
- The recordStatusUnchecked function changes the status without checking its previous value.
- The moveJob function selects that unchecked path while protection is disabled.
- The publicJob function adds the current queue depth to the job returned through the API.
- Save server.js.
- Return to the integrated terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Check the new state for syntax errors by restarting the server with this command:
node server.js
What should you see?
The terminal prints the local HeightCheck address. This confirms the new state loads without a syntax error.
Server not restarting?
Confirm the previous server stopped before you restart it. Check that the UUID import sits with the other imports near the top of server.js.
Use this if the server still exits before showing its local address: Help me diagnose why my Node.js server exits after I added the job queue state.
A queue only stores messages. The worker provides the visible side effect by removing each message and running the verification calculation.
- Add the queue producer plus the deliberately unsafe message processor below publicJob by copying this code:
function enqueueJob(job) {
const message = {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
};
queue.push({ ...message });
if (job.input.duplicateDelivery) queue.push({ ...message });
setTimeout(() => drainQueue(), 0);
}
async function processMessage(message) {
const job = jobs.get(message.jobId);
if (!job) return;
job.attempts += 1;
job.processCount += 1;
moveJob(job, 'processing', `Worker started attempt ${job.attempts}`);
await sleep(PROCESSING_DELAY_MS);
job.result = calculateVerification(job.input);
moveJob(job, job.result.status, `Verification completed on attempt ${job.attempts}`);
}
How does a message become work?
- The enqueueJob function creates a small message containing the job ID plus its idempotency key.
- A checked duplicate option places two copies of that message onto the local queue.
- The processMessage function increments both counters before applying the simulated delay.
- Each processed copy writes its own processing event plus completion event into the history.
- Add the queue drain loop below processMessage by copying this code:
async function drainQueue() {
if (workerRunning) return;
workerRunning = true;
try {
while (queue.length > 0) {
const message = queue.shift();
await processMessage(message);
}
} finally {
workerRunning = false;
}
}
What does the drain loop protect?
The loop processes queued messages one at a time. Its finally block releases the worker flag even if message processing stops with an error.
- Save server.js.
- Return to the integrated terminal.
- Stop the current server by pressing Ctrl+C.
- Confirm the worker code loads by restarting the server with this command:
node server.js
What should you see now?
The terminal prints the HeightCheck address again. The queue worker is now loaded and waiting for the API to create a job.
Expose the asynchronous job API
The browser needs one route for submitting work. It needs another route for reading the changing job state.
- Find the POST /api/verify-sync block inside the server request handler.
- Add the asynchronous submission route directly below that block by copying this code:
if (request.method === 'POST' && url.pathname === '/api/jobs') {
const input = validateInput(await readJson(request));
const job = {
id: randomUUID(),
idempotencyKey: randomUUID(),
status: 'queued',
attempts: 0,
processCount: 0,
input,
result: null,
lastError: null,
createdAt: timestamp(),
history: [{ status: 'queued', at: timestamp(), note: 'Job accepted' }],
};
jobs.set(job.id, job);
enqueueJob(job);
sendJson(response, 202, {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
status: job.status,
});
return;
}
What does job submission do?
- The route validates the same controlled-reference inputs used by synchronous verification.
- Two calls to randomUUID() create separate identifiers for tracking plus logical deduplication.
- The initial history records that the server accepted the job in queued status.
- The HTTP 202 response returns before the worker finishes the delayed calculation.
- Add the status route below the new submission route by copying this code:
const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (request.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) {
sendJson(response, 404, { error: 'Job not found' });
return;
}
sendJson(response, 200, publicJob(job));
return;
}
How does polling find the job?
The route extracts the identifier from the URL. It returns the matching job plus its latest counters and audit history.
- Save server.js.
- Return to the integrated terminal.
- Stop the current server by pressing Ctrl+C.
- Confirm both routes load by restarting the server with this command:
node server.js
What should you see?
The terminal prints the HeightCheck address without exiting. Your synchronous route plus both asynchronous job routes are now available.
✔️ Awesome, I've got everything!
Your asynchronous server code now matches this step.
ⓧ I'd like to double check the full code
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const PORT = 3000;
const PROCESSING_DELAY_MS = 1200;
const IDEMPOTENCY_ENABLED = false;
const jobs = new Map();
const queue = [];
let workerRunning = false;
function timestamp() {
return new Date().toISOString();
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
request.destroy(new Error('Request body is too large'));
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
request.on('error', reject);
});
}
function validateInput(payload) {
const fields = [
'claimedHeightCm',
'personPixels',
'referencePixels',
'referenceHeightCm',
];
for (const field of fields) {
if (typeof payload[field] !== 'number' || !Number.isFinite(payload[field]) || payload[field] <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
}
return {
claimedHeightCm: payload.claimedHeightCm,
personPixels: payload.personPixels,
referencePixels: payload.referencePixels,
referenceHeightCm: payload.referenceHeightCm,
duplicateDelivery: Boolean(payload.duplicateDelivery),
forceFailure: Boolean(payload.forceFailure),
};
}
function calculateVerification(input) {
const estimatedHeightCm = Number(
((input.personPixels / input.referencePixels) * input.referenceHeightCm).toFixed(1),
);
const differenceCm = Number(Math.abs(estimatedHeightCm - input.claimedHeightCm).toFixed(1));
let status = 'rejected';
if (differenceCm <= 2) status = 'verified';
else if (differenceCm <= 5) status = 'manual_review';
return {
estimatedHeightCm,
differenceCm,
status,
disclaimer: 'Demo confidence signal only. This is not proof of height.',
};
}
function recordStatusUnchecked(job, nextStatus, note) {
job.status = nextStatus;
job.history.push({ status: nextStatus, at: timestamp(), note });
}
function moveJob(job, nextStatus, note) {
if (IDEMPOTENCY_ENABLED) transitionJob(job, nextStatus, note);
else recordStatusUnchecked(job, nextStatus, note);
}
function publicJob(job) {
return {
...job,
queueDepth: queue.length,
};
}
function enqueueJob(job) {
const message = {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
};
queue.push({ ...message });
if (job.input.duplicateDelivery) queue.push({ ...message });
setTimeout(() => drainQueue(), 0);
}
async function processMessage(message) {
const job = jobs.get(message.jobId);
if (!job) return;
job.attempts += 1;
job.processCount += 1;
moveJob(job, 'processing', `Worker started attempt ${job.attempts}`);
await sleep(PROCESSING_DELAY_MS);
job.result = calculateVerification(job.input);
moveJob(job, job.result.status, `Verification completed on attempt ${job.attempts}`);
}
async function drainQueue() {
if (workerRunning) return;
workerRunning = true;
try {
while (queue.length > 0) {
const message = queue.shift();
await processMessage(message);
}
} finally {
workerRunning = false;
}
}
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
if (request.method === 'POST' && url.pathname === '/api/verify-sync') {
const input = validateInput(await readJson(request));
await sleep(PROCESSING_DELAY_MS);
sendJson(response, 200, calculateVerification(input));
return;
}
if (request.method === 'POST' && url.pathname === '/api/jobs') {
const input = validateInput(await readJson(request));
const job = {
id: randomUUID(),
idempotencyKey: randomUUID(),
status: 'queued',
attempts: 0,
processCount: 0,
input,
result: null,
lastError: null,
createdAt: timestamp(),
history: [{ status: 'queued', at: timestamp(), note: 'Job accepted' }],
};
jobs.set(job.id, job);
enqueueJob(job);
sendJson(response, 202, {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
status: job.status,
});
return;
}
const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (request.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) {
sendJson(response, 404, { error: 'Job not found' });
return;
}
sendJson(response, 200, publicJob(job));
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
});
Poll the job and expose duplicate processing
The server now accepts work without holding the submission request open. The browser needs to render each changing snapshot until the job reaches a terminal status.
- Return to public/app.js.
- Add the terminal-status set below the existing DOM references by copying this line:
const terminalStatuses = new Set(['verified', 'manual_review', 'rejected', 'failed']);
Why define terminal statuses?
Polling needs a clear stopping condition. These values tell the browser that no further status request is needed.
- Add the text plus history renderers below requestJson by copying this code:
function setText(selector, value) {
document.querySelector(selector).textContent = String(value);
}
function renderHistory(history) {
historyBody.replaceChildren();
for (const entry of history) {
const row = document.createElement('tr');
const time = document.createElement('td');
const status = document.createElement('td');
const note = document.createElement('td');
time.textContent = new Date(entry.at).toLocaleTimeString();
status.textContent = entry.status;
note.textContent = entry.note;
row.append(time, status, note);
historyBody.append(row);
}
}
How does the audit table update?
The renderer clears the previous rows before rebuilding them from the latest history. Each event receives a time cell plus a status cell plus a note cell.
- Save public/app.js.
- Return to the HeightCheck tab in Safari.
- Refresh http://localhost:3000.
You should still see the HeightCheck form plus the empty Job Audit panel. This confirms the new rendering helpers load without breaking the existing interface.
- Return to public/app.js.
- Add the job renderer plus polling loop below renderHistory by copying this code:
function renderJob(job) {
setText('#job-id', job.id);
setText('#idempotency-key', job.idempotencyKey);
setText('#job-status', job.status);
setText('#job-attempts', job.attempts);
setText('#process-count', job.processCount);
renderHistory(job.history);
const result = document.querySelector('#result');
if (job.result) {
result.textContent = `${job.result.status}: estimated ${job.result.estimatedHeightCm} cm, difference ${job.result.differenceCm} cm. ${job.result.disclaimer}`;
} else if (job.lastError) {
result.textContent = `Last error: ${job.lastError}`;
} else {
result.textContent = 'Verification is still running.';
}
}
async function pollJob(jobId) {
while (true) {
const job = await requestJson(`/api/jobs/${jobId}`);
renderJob(job);
if (terminalStatuses.has(job.status)) return job;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
How does polling reveal progress?
- The renderJob function updates the identifiers plus counters plus result panel from one job snapshot.
- The pollJob function requests a fresh snapshot every 250 milliseconds.
- The loop exits after the worker records a terminal status.
- Add the asynchronous submit handler below the existing synchronous click handler by copying this code:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const startedAt = performance.now();
summary.textContent = 'Submitting asynchronous job...';
try {
const accepted = await requestJson('/api/jobs', {
method: 'POST',
body: JSON.stringify(inputPayload()),
});
const acceptanceDuration = Math.round(performance.now() - startedAt);
summary.textContent = `Job accepted in ${acceptanceDuration} ms. Polling ${accepted.jobId}.`;
await pollJob(accepted.jobId);
} catch (error) {
summary.textContent = error.message;
}
});
What does submission measure?
The handler measures how quickly the server accepts the job. It then passes the returned identifier to the polling loop while the worker continues separately.
- Save public/app.js.
✔️ Awesome, I've got everything!
Your browser script now submits jobs plus polls their audit state.
ⓧ I'd like to double check the full code
const form = document.querySelector('#verification-form');
const syncButton = document.querySelector('#sync-button');
const summary = document.querySelector('#summary');
const historyBody = document.querySelector('#history-body');
const terminalStatuses = new Set(['verified', 'manual_review', 'rejected', 'failed']);
function inputPayload() {
return {
claimedHeightCm: Number(document.querySelector('#claimed-height').value),
personPixels: Number(document.querySelector('#person-pixels').value),
referencePixels: Number(document.querySelector('#reference-pixels').value),
referenceHeightCm: Number(document.querySelector('#reference-height').value),
duplicateDelivery: document.querySelector('#duplicate-delivery').checked,
forceFailure: document.querySelector('#force-failure').checked,
};
}
async function requestJson(pathname, options = {}) {
const response = await fetch(pathname, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Request failed');
return payload;
}
function setText(selector, value) {
document.querySelector(selector).textContent = String(value);
}
function renderHistory(history) {
historyBody.replaceChildren();
for (const entry of history) {
const row = document.createElement('tr');
const time = document.createElement('td');
const status = document.createElement('td');
const note = document.createElement('td');
time.textContent = new Date(entry.at).toLocaleTimeString();
status.textContent = entry.status;
note.textContent = entry.note;
row.append(time, status, note);
historyBody.append(row);
}
}
function renderJob(job) {
setText('#job-id', job.id);
setText('#idempotency-key', job.idempotencyKey);
setText('#job-status', job.status);
setText('#job-attempts', job.attempts);
setText('#process-count', job.processCount);
renderHistory(job.history);
const result = document.querySelector('#result');
if (job.result) {
result.textContent = `${job.result.status}: estimated ${job.result.estimatedHeightCm} cm, difference ${job.result.differenceCm} cm. ${job.result.disclaimer}`;
} else if (job.lastError) {
result.textContent = `Last error: ${job.lastError}`;
} else {
result.textContent = 'Verification is still running.';
}
}
async function pollJob(jobId) {
while (true) {
const job = await requestJson(`/api/jobs/${jobId}`);
renderJob(job);
if (terminalStatuses.has(job.status)) return job;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
syncButton.addEventListener('click', async () => {
const startedAt = performance.now();
summary.textContent = 'Synchronous request is waiting for the verifier...';
try {
const result = await requestJson('/api/verify-sync', {
method: 'POST',
body: JSON.stringify(inputPayload()),
});
const duration = Math.round(performance.now() - startedAt);
summary.textContent = `Synchronous result: ${result.status}, estimated ${result.estimatedHeightCm} cm, ${duration} ms round trip.`;
} catch (error) {
summary.textContent = error.message;
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const startedAt = performance.now();
summary.textContent = 'Submitting asynchronous job...';
try {
const accepted = await requestJson('/api/jobs', {
method: 'POST',
body: JSON.stringify(inputPayload()),
});
const acceptanceDuration = Math.round(performance.now() - startedAt);
summary.textContent = `Job accepted in ${acceptanceDuration} ms. Polling ${accepted.jobId}.`;
await pollJob(accepted.jobId);
} catch (error) {
summary.textContent = error.message;
}
});
Before you submit the job, do you think one logical job will cause one processing side effect or two?
Allow roughly two and a half seconds for the audit to settle. The local worker applies its simulated delay to each delivered message.
- Return to the HeightCheck page in Safari.
- Refresh http://localhost:3000.
- Keep Deliver the same message twice checked.
- Keep Simulate verifier outage unchecked.
- Click Submit async job.
First, the summary reports that the server accepted the job quickly. The browser keeps polling while the worker handles both queued copies.
Once the audit settles, Process count reads 2. The history contains two processing events plus two completion events.
The duplicate is the intended result
You have reproduced the correctness problem behind at-least-once delivery. The asynchronous response improves responsiveness while the repeated message performs the verification side effect twice.
Process count stays at one?
Confirm Deliver the same message twice was checked before submission. Confirm IDEMPOTENCY_ENABLED is set to false in server.js.
Use this if the audit still stops after one attempt: Help me trace why my duplicate HeightCheck job only processes once.
You have gained the fast acceptance of an asynchronous API while exposing its duplicate-processing risk. Next, you will make repeated deliveries harmless without losing the audit trail.
Make the Consumer Idempotent
Your asynchronous Node.js API now accepts jobs immediately. Its audit history also proved that one duplicated message can run the same verification side effect twice.
An idempotent consumer recognizes repeated logical work before performing another side effect. An idempotency key gives each logical job a stable identity.
A guarded state transition blocks stale messages from moving a completed job backwards. These controls preserve one stable terminal result.
In this step, get ready to:
- Track completed idempotency keys.
- Guard every job status transition.
- Prove duplicate delivery causes one verification side effect.
Recognize repeated delivery
An idempotency key identifies one logical job across repeated queue deliveries. The consumer can use that key to recognize work it has already completed.
- Switch back to server.js in Visual Studio Code.
- Find the jobs collection near the top of the file.
- Replace the collection declarations with this reference block:
const jobs = new Map();
const queue = [];
const completedIdempotencyKeys = new Set();
let workerRunning = false;
What Does This Collection Track?
- The completedIdempotencyKeys set stores keys whose verification work has reached a terminal result.
- A set keeps each key unique. Repeated additions cannot create duplicate entries.
- Find if (!job) return; near the start of processMessage().
- Add this guard immediately below that line:
if (
IDEMPOTENCY_ENABLED &&
(completedIdempotencyKeys.has(message.idempotencyKey) ||
message.idempotencyKey !== job.idempotencyKey ||
job.status !== 'queued')
) {
job.history.push({
status: 'duplicate_skipped',
at: timestamp(),
note: `Skipped repeated delivery for key ${message.idempotencyKey}`,
});
return;
}
How Does the Guard Work?
- The completed-key check catches logical work that already reached a terminal result.
- The key-matching check rejects a message whose key does not belong to the stored job.
- The status check rejects a message when its job is no longer waiting in queued.
- The duplicate_skipped event preserves evidence of the delivery without repeating verification.
- Save server.js.
- Stop the current server process in the integrated terminal.
- Confirm the new guard has valid syntax by restarting the server with this command:
node server.js
What Should You See?
You should see HeightCheck is running at http://localhost:3000 in the terminal. This proves the server can load the new collection and guard.
- Refresh http://localhost:3000 in Safari.
You should see the HeightCheck form with the Job Audit panel beside it.
Server Fails to Restart?
Check that the new guard sits inside processMessage() after the missing-job check. Confirm every opening bracket has a matching closing bracket.
The terminal points to the first line Node.js cannot parse. Help me find the syntax problem
A successful verification must record its key before another delivery can be evaluated. This turns the completed-key set into durable memory for the lifetime of the local server process.
- Find the successful terminal-status update inside processMessage().
- Add this completion record immediately after that update:
completedIdempotencyKeys.add(message.idempotencyKey);
Why Record the Key Here?
The key enters the set only after verification succeeds. A later copy of the same message can now be recognized before its side effect runs.
- Find the IDEMPOTENCY_ENABLED configuration line near the top of server.js.
- Replace its current line with this enabled configuration:
const IDEMPOTENCY_ENABLED = true;
What Does the Switch Control?
The enabled value activates the duplicate guard. Every delivered message now has to pass its key and status checks before processing begins.
- Save server.js.
- Stop the current server process in the integrated terminal.
- Load the enabled protection by restarting the server with this command:
node server.js
Confirm Protection Is Enabled
You should see Idempotency protection: ON below the local server address. The consumer now evaluates every queue message through the new guard.
Guard every state change
Key deduplication recognizes repeated work. A state machine adds a second boundary by defining which status changes are legal.
The transition graph allows a job to move forward through processing. A terminal job has no outgoing transition.
- Find the workerRunning declaration near the top of server.js.
- Add this transition graph immediately below that declaration:
const ALLOWED_TRANSITIONS = {
queued: new Set(['processing']),
processing: new Set(['verified', 'manual_review', 'rejected', 'retrying', 'failed']),
retrying: new Set(['queued']),
};
What Does the Graph Allow?
- A queued job can start processing.
- A processing job can reach a result or a controlled recovery status.
- A retrying job can return to queued.
- Terminal statuses have no entries. They cannot move backwards.
- Find the end of calculateVerification().
- Add these status helpers below that function:
function recordStatusUnchecked(job, nextStatus, note) {
job.status = nextStatus;
job.history.push({ status: nextStatus, at: timestamp(), note });
}
function transitionJob(job, nextStatus, note) {
const allowed = ALLOWED_TRANSITIONS[job.status];
if (!allowed || !allowed.has(nextStatus)) {
throw new Error(`Illegal transition: ${job.status} -> ${nextStatus}`);
}
recordStatusUnchecked(job, nextStatus, note);
}
function moveJob(job, nextStatus, note) {
if (IDEMPOTENCY_ENABLED) transitionJob(job, nextStatus, note);
else recordStatusUnchecked(job, nextStatus, note);
}
How Are Status Changes Protected?
- The recordStatusUnchecked() helper updates the status and appends its audit event.
- The transitionJob() helper rejects any move missing from the transition graph.
- The moveJob() helper routes updates through the graph while idempotency protection is enabled.
- Save server.js.
- Stop the current server process in the integrated terminal.
- Confirm the transition helpers load by restarting the server with this command:
node server.js
What Should You See?
You should see the HeightCheck address followed by Idempotency protection: ON. The state-machine definitions are valid JavaScript.
- In processMessage(), locate the status assignment immediately after job.processCount += 1;.
- Replace that status-assignment block with this guarded update:
moveJob(job, 'processing', `Worker started attempt ${job.attempts}`);
Why Guard the Start?
The worker can enter processing only from queued. A stale delivery cannot restart a terminal job.
- Find the successful status-assignment block after calculateVerification().
- Replace that block with this guarded terminal update:
moveJob(job, job.result.status, `Verification completed on attempt ${job.attempts}`);
Why Guard Completion?
The result status can now follow only processing. Once the job reaches that terminal result, the graph provides no route back.
Prove duplicate delivery is harmless
The consumer now checks both logical identity and job state. The final test repeats the unsafe scenario from the previous step.
- Save server.js.
- Stop the current server process in the integrated terminal.
- Start the protected consumer by running this command:
node server.js
Confirm the Protected Server
You should see Idempotency protection: ON in the terminal. Keep this process running during the browser test.
- Refresh http://localhost:3000 in Safari.
- Leave the four numeric inputs at their default values.
- Keep Deliver the same message twice checked.
- Keep Simulate verifier outage unchecked.
Before you submit the job, do you expect both message copies to reach the verification side effect?
- Click Submit async job.
- Watch the Job Audit panel until the status becomes verified.
You should see Process count remain at 1. The result should report an estimated height of 180 cm.
The audit history should contain one processing event. It should contain one terminal verified event.
The second delivery should appear as duplicate_skipped. That audit entry does not change the terminal result.
That is the core correctness win: repeated delivery now leaves one stable verification result.
Process Count Still Reaches Two?
Confirm the terminal prints Idempotency protection: ON. Restart the server if it still reports the disabled state.
Check that the duplicate guard appears before job.attempts += 1; inside processMessage(). A later guard allows both copies to increment the counters.
Help me debug the duplicate side effect
✔️ Awesome, I've got everything!
Your consumer now skips repeated deliveries and preserves one terminal result.
ⓧ I'd like to double check the full code
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const PORT = 3000;
const PROCESSING_DELAY_MS = 1200;
const IDEMPOTENCY_ENABLED = true;
const jobs = new Map();
const queue = [];
const completedIdempotencyKeys = new Set();
let workerRunning = false;
const ALLOWED_TRANSITIONS = {
queued: new Set(['processing']),
processing: new Set(['verified', 'manual_review', 'rejected', 'retrying', 'failed']),
retrying: new Set(['queued']),
};
function timestamp() {
return new Date().toISOString();
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
request.destroy(new Error('Request body is too large'));
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
request.on('error', reject);
});
}
function validateInput(payload) {
const fields = [
'claimedHeightCm',
'personPixels',
'referencePixels',
'referenceHeightCm',
];
for (const field of fields) {
if (typeof payload[field] !== 'number' || !Number.isFinite(payload[field]) || payload[field] <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
}
return {
claimedHeightCm: payload.claimedHeightCm,
personPixels: payload.personPixels,
referencePixels: payload.referencePixels,
referenceHeightCm: payload.referenceHeightCm,
duplicateDelivery: Boolean(payload.duplicateDelivery),
forceFailure: Boolean(payload.forceFailure),
};
}
function calculateVerification(input) {
const estimatedHeightCm = Number(
((input.personPixels / input.referencePixels) * input.referenceHeightCm).toFixed(1),
);
const differenceCm = Number(Math.abs(estimatedHeightCm - input.claimedHeightCm).toFixed(1));
let status = 'rejected';
if (differenceCm <= 2) status = 'verified';
else if (differenceCm <= 5) status = 'manual_review';
return {
estimatedHeightCm,
differenceCm,
status,
disclaimer: 'Demo confidence signal only. This is not proof of height.',
};
}
function recordStatusUnchecked(job, nextStatus, note) {
job.status = nextStatus;
job.history.push({ status: nextStatus, at: timestamp(), note });
}
function transitionJob(job, nextStatus, note) {
const allowed = ALLOWED_TRANSITIONS[job.status];
if (!allowed || !allowed.has(nextStatus)) {
throw new Error(`Illegal transition: ${job.status} -> ${nextStatus}`);
}
recordStatusUnchecked(job, nextStatus, note);
}
function moveJob(job, nextStatus, note) {
if (IDEMPOTENCY_ENABLED) transitionJob(job, nextStatus, note);
else recordStatusUnchecked(job, nextStatus, note);
}
function publicJob(job) {
return {
...job,
queueDepth: queue.length,
};
}
function enqueueJob(job) {
const message = {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
};
queue.push({ ...message });
if (job.input.duplicateDelivery) queue.push({ ...message });
setTimeout(() => drainQueue(), 0);
}
async function processMessage(message) {
const job = jobs.get(message.jobId);
if (!job) return;
if (
IDEMPOTENCY_ENABLED &&
(completedIdempotencyKeys.has(message.idempotencyKey) ||
message.idempotencyKey !== job.idempotencyKey ||
job.status !== 'queued')
) {
job.history.push({
status: 'duplicate_skipped',
at: timestamp(),
note: `Skipped repeated delivery for key ${message.idempotencyKey}`,
});
return;
}
job.attempts += 1;
job.processCount += 1;
moveJob(job, 'processing', `Worker started attempt ${job.attempts}`);
await sleep(PROCESSING_DELAY_MS);
job.result = calculateVerification(job.input);
moveJob(job, job.result.status, `Verification completed on attempt ${job.attempts}`);
completedIdempotencyKeys.add(message.idempotencyKey);
}
async function drainQueue() {
if (workerRunning) return;
workerRunning = true;
try {
while (queue.length > 0) {
const message = queue.shift();
await processMessage(message);
}
} finally {
workerRunning = false;
}
}
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
if (request.method === 'POST' && url.pathname === '/api/verify-sync') {
const input = validateInput(await readJson(request));
await sleep(PROCESSING_DELAY_MS);
sendJson(response, 200, calculateVerification(input));
return;
}
if (request.method === 'POST' && url.pathname === '/api/jobs') {
const input = validateInput(await readJson(request));
const job = {
id: randomUUID(),
idempotencyKey: randomUUID(),
status: 'queued',
attempts: 0,
processCount: 0,
input,
result: null,
lastError: null,
createdAt: timestamp(),
history: [{ status: 'queued', at: timestamp(), note: 'Job accepted' }],
};
jobs.set(job.id, job);
enqueueJob(job);
sendJson(response, 202, {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
status: job.status,
});
return;
}
const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (request.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) {
sendJson(response, 404, { error: 'Job not found' });
return;
}
sendJson(response, 200, publicJob(job));
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
console.log(`Idempotency protection: ${IDEMPOTENCY_ENABLED ? 'ON' : 'OFF'}`);
});
What Should the Full File Contain?
The completed file combines key deduplication with guarded status transitions. Its worker checks every message before incrementing either processing counter.
Your consumer now stays correct when the queue repeats a message. Next up, you'll add bounded retries and a dead-letter destination for persistent verifier failures.
Add Retries, a DLQ, and AWS Mapping
Your idempotent consumer now keeps duplicate deliveries from repeating a successful side effect. A persistent verifier outage still needs a bounded path to failure.
This step adds bounded retries with exponential backoff. A dead-letter queue makes exhausted jobs inspectable.
In this step, get ready to:
- Retry failed verification jobs with bounded exponential delays.
- Move exhausted jobs into a dead-letter queue.
- Document the production AWS architecture mapping.
Bound retries with exponential backoff
A transient outage may disappear on a later attempt. A fixed attempt limit prevents one broken job from retrying forever.
- Switch back to server.js in Visual Studio Code.
- Find the declarations from PROCESSING_DELAY_MS through completedIdempotencyKeys.
- Replace that declaration block with this code:
const PROCESSING_DELAY_MS = 1200;
const MAX_ATTEMPTS = 3;
const BASE_RETRY_DELAY_MS = 300;
const IDEMPOTENCY_ENABLED = true;
const jobs = new Map();
const queue = [];
const deadLetterQueue = [];
const completedIdempotencyKeys = new Set();
How do these retry controls work?
- The MAX_ATTEMPTS value caps each job at three processing attempts.
- The BASE_RETRY_DELAY_MS value starts the retry delay at 300 milliseconds.
- The deadLetterQueue array stores messages that exhaust every attempt.
- Find the catch (error) block inside processMessage().
- Replace that entire block with this retry path:
} catch (error) {
job.lastError = error.message;
if (job.attempts < MAX_ATTEMPTS) {
const delay = BASE_RETRY_DELAY_MS * 2 ** (job.attempts - 1);
moveJob(job, 'retrying', `Retry scheduled in ${delay} ms`);
setTimeout(() => {
if (job.status !== 'retrying') return;
moveJob(job, 'queued', `Retry ${job.attempts + 1} queued`);
queue.push({ ...message });
drainQueue();
}, delay);
return;
}
moveJob(job, 'failed', `Attempts exhausted: ${error.message}`);
completedIdempotencyKeys.add(message.idempotencyKey);
deadLetterQueue.push({
...message,
failedAt: timestamp(),
reason: error.message,
});
}
What does this retry path do?
- The delay doubles from 300 milliseconds to 600 milliseconds across the two retries.
- Every retry uses moveJob() to follow the guarded processing, retrying, and queued transitions.
- The third failure changes the job to failed.
- Completing the idempotency key makes later duplicate deliveries harmless.
- Save server.js.
- Return to the integrated terminal in Visual Studio Code.
- Stop the current server with Ctrl+C.
- Restart HeightCheck by running this command:
node server.js
What should I see?
The terminal confirms that HeightCheck is running at http://localhost:3000. It also reports that idempotency protection is on.
- Switch back to the HeightCheck tab in Safari.
- Select Simulate verifier outage.
- Confirm that Deliver the same message twice remains selected.
Before you submit the job, how many processing attempts do you expect before the retry limit takes over?
- Click Submit async job.
You will see the attempt count reach 3. The final status becomes failed.
That failure is controlled. The duplicate delivery adds a duplicate_skipped event without creating a fourth attempt.
Job does not reach three attempts?
Confirm that forceFailure is included in validateInput(). Check that the browser checkbox is selected before submission.
Check that retrying can transition to queued in ALLOWED_TRANSITIONS.
Help me debug the bounded retry flow.
Expose the dead-letter queue
A failed status tells the client that processing ended. The dead-letter queue preserves the exhausted message for inspection.
- Find the existing publicJob() function in server.js.
- Replace that function with this version:
function publicJob(job) {
return {
...job,
queueDepth: queue.length,
deadLetterCount: deadLetterQueue.length,
};
}
What does this response expose?
Each polled job now includes the current queue depth. It also includes the number of messages in deadLetterQueue.
The existing Job Audit panel reads deadLetterCount and displays it beside the failed job.
- Find the job status route above the final route fallback in server.js.
- Add this dead-letter route below it:
if (request.method === 'GET' && url.pathname === '/api/dlq') {
sendJson(response, 200, {
count: deadLetterQueue.length,
messages: deadLetterQueue,
});
return;
}
What does this route return?
The GET /api/dlq endpoint returns the total dead-letter count. Its messages array exposes the job identifier, idempotency key, failure time, and reason.
- Save server.js.
- Return to the integrated terminal.
- Stop the current server with Ctrl+C.
- Restart the server by running this command:
node server.js
Why is the DLQ empty again?
The local jobs and dead-letter messages live in memory. Restarting the server resets both collections.
- Open a new tab in Safari.
- Enter http://localhost:3000/api/dlq in the address bar.
You should see a JSON response with a count of 0. The messages array is empty.
Dead-letter route does not load?
Confirm that the route appears before the final Route not found response. Restart the server after saving server.js.
Help me debug the dead-letter endpoint.
✔️ Awesome, I've got everything!
Great. Save server.js before you document the production architecture.
ⓧ I'd like to double check the full code
const http = require('node:http');
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const PORT = 3000;
const PROCESSING_DELAY_MS = 1200;
const MAX_ATTEMPTS = 3;
const BASE_RETRY_DELAY_MS = 300;
const IDEMPOTENCY_ENABLED = true;
const jobs = new Map();
const queue = [];
const deadLetterQueue = [];
const completedIdempotencyKeys = new Set();
let workerRunning = false;
const ALLOWED_TRANSITIONS = {
queued: new Set(['processing']),
processing: new Set(['verified', 'manual_review', 'rejected', 'retrying', 'failed']),
retrying: new Set(['queued']),
};
function timestamp() {
return new Date().toISOString();
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
request.destroy(new Error('Request body is too large'));
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Request body must be valid JSON'));
}
});
request.on('error', reject);
});
}
function validateInput(payload) {
const fields = [
'claimedHeightCm',
'personPixels',
'referencePixels',
'referenceHeightCm',
];
for (const field of fields) {
if (typeof payload[field] !== 'number' || !Number.isFinite(payload[field]) || payload[field] <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
}
return {
claimedHeightCm: payload.claimedHeightCm,
personPixels: payload.personPixels,
referencePixels: payload.referencePixels,
referenceHeightCm: payload.referenceHeightCm,
duplicateDelivery: Boolean(payload.duplicateDelivery),
forceFailure: Boolean(payload.forceFailure),
};
}
function calculateVerification(input) {
const estimatedHeightCm = Number(
((input.personPixels / input.referencePixels) * input.referenceHeightCm).toFixed(1),
);
const differenceCm = Number(Math.abs(estimatedHeightCm - input.claimedHeightCm).toFixed(1));
let status = 'rejected';
if (differenceCm <= 2) status = 'verified';
else if (differenceCm <= 5) status = 'manual_review';
return {
estimatedHeightCm,
differenceCm,
status,
disclaimer: 'Demo confidence signal only. This is not proof of height.',
};
}
function recordStatusUnchecked(job, nextStatus, note) {
job.status = nextStatus;
job.history.push({ status: nextStatus, at: timestamp(), note });
}
function transitionJob(job, nextStatus, note) {
const allowed = ALLOWED_TRANSITIONS[job.status];
if (!allowed || !allowed.has(nextStatus)) {
throw new Error(`Illegal transition: ${job.status} -> ${nextStatus}`);
}
recordStatusUnchecked(job, nextStatus, note);
}
function moveJob(job, nextStatus, note) {
if (IDEMPOTENCY_ENABLED) transitionJob(job, nextStatus, note);
else recordStatusUnchecked(job, nextStatus, note);
}
function publicJob(job) {
return {
...job,
queueDepth: queue.length,
deadLetterCount: deadLetterQueue.length,
};
}
function enqueueJob(job) {
const message = {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
};
queue.push({ ...message });
if (job.input.duplicateDelivery) queue.push({ ...message });
setTimeout(() => drainQueue(), 0);
}
async function processMessage(message) {
const job = jobs.get(message.jobId);
if (!job) return;
if (
IDEMPOTENCY_ENABLED &&
(completedIdempotencyKeys.has(message.idempotencyKey) ||
message.idempotencyKey !== job.idempotencyKey ||
job.status !== 'queued')
) {
job.history.push({
status: 'duplicate_skipped',
at: timestamp(),
note: `Skipped repeated delivery for key ${message.idempotencyKey}`,
});
return;
}
job.attempts += 1;
job.processCount += 1;
moveJob(job, 'processing', `Worker started attempt ${job.attempts}`);
try {
await sleep(PROCESSING_DELAY_MS);
if (job.input.forceFailure) {
throw new Error('Simulated verifier outage');
}
job.result = calculateVerification(job.input);
moveJob(job, job.result.status, `Verification completed on attempt ${job.attempts}`);
completedIdempotencyKeys.add(message.idempotencyKey);
} catch (error) {
job.lastError = error.message;
if (job.attempts < MAX_ATTEMPTS) {
const delay = BASE_RETRY_DELAY_MS * 2 ** (job.attempts - 1);
moveJob(job, 'retrying', `Retry scheduled in ${delay} ms`);
setTimeout(() => {
if (job.status !== 'retrying') return;
moveJob(job, 'queued', `Retry ${job.attempts + 1} queued`);
queue.push({ ...message });
drainQueue();
}, delay);
return;
}
moveJob(job, 'failed', `Attempts exhausted: ${error.message}`);
completedIdempotencyKeys.add(message.idempotencyKey);
deadLetterQueue.push({
...message,
failedAt: timestamp(),
reason: error.message,
});
}
}
async function drainQueue() {
if (workerRunning) return;
workerRunning = true;
try {
while (queue.length > 0) {
const message = queue.shift();
await processMessage(message);
}
} finally {
workerRunning = false;
}
}
async function serveStatic(pathname, response) {
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
};
const file = files[pathname];
if (!file) return false;
const [filename, contentType] = file;
const contents = await readFile(path.join(__dirname, 'public', filename));
response.writeHead(200, { 'Content-Type': contentType });
response.end(contents);
return true;
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && (await serveStatic(url.pathname, response))) {
return;
}
if (request.method === 'POST' && url.pathname === '/api/verify-sync') {
const input = validateInput(await readJson(request));
await sleep(PROCESSING_DELAY_MS);
sendJson(response, 200, calculateVerification(input));
return;
}
if (request.method === 'POST' && url.pathname === '/api/jobs') {
const input = validateInput(await readJson(request));
const job = {
id: randomUUID(),
idempotencyKey: randomUUID(),
status: 'queued',
attempts: 0,
processCount: 0,
input,
result: null,
lastError: null,
createdAt: timestamp(),
history: [{ status: 'queued', at: timestamp(), note: 'Job accepted' }],
};
jobs.set(job.id, job);
enqueueJob(job);
sendJson(response, 202, {
jobId: job.id,
idempotencyKey: job.idempotencyKey,
status: job.status,
});
return;
}
const jobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (request.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) {
sendJson(response, 404, { error: 'Job not found' });
return;
}
sendJson(response, 200, publicJob(job));
return;
}
if (request.method === 'GET' && url.pathname === '/api/dlq') {
sendJson(response, 200, {
count: deadLetterQueue.length,
messages: deadLetterQueue,
});
return;
}
sendJson(response, 404, { error: 'Route not found' });
} catch (error) {
sendJson(response, 400, { error: error.message });
}
});
server.listen(PORT, () => {
console.log(`HeightCheck is running at http://localhost:${PORT}`);
console.log(`Idempotency protection: ${IDEMPOTENCY_ENABLED ? 'ON' : 'OFF'}`);
});
Map the local pipeline to AWS
Your local queue maps to Amazon SQS. The jobs map becomes an Amazon DynamoDB table.
Amazon API Gateway accepts production requests. AWS Lambda handles API logic.
Amazon S3 keeps sensitive evidence outside the API payload. A presigned URL lets the client upload the object without receiving AWS credentials.
- Switch back to architecture.md in Visual Studio Code.
- Add this mapping below the existing Scope section:
## Local to AWS mapping
| Local component | AWS production counterpart | Responsibility |
| --- | --- | --- |
| Browser form | Web or mobile client | Collect claim and controlled-reference evidence |
| Node.js HTTP routes | Amazon API Gateway plus AWS Lambda | Validate requests, create jobs, and return status |
| Evidence fields in memory | Private Amazon S3 object uploaded through a presigned URL | Store sensitive evidence outside the API payload |
| `jobs` Map | Amazon DynamoDB | Store job state, idempotency key, result, and audit metadata |
| `queue` array | Amazon SQS standard queue | Decouple job acceptance from verification work |
| `processMessage` | AWS Lambda consumer | Run verification and update job state |
| `deadLetterQueue` array | Amazon SQS dead-letter queue | Isolate messages that exhaust retries |
Why map the simulation to AWS?
The local arrays make queue behavior visible without cloud setup. The mapping shows how the same responsibilities separate across managed production services.
- Add the production job contract below the mapping table:
## API contract
1. `POST /jobs` validates metadata, creates a job, returns its identifier, and sends a queue message.
2. The client polls `GET /jobs/{jobId}` until the job reaches a terminal state.
3. In production, the client first requests a short-lived Amazon S3 presigned upload URL, uploads evidence directly, and submits the resulting object key with the job metadata.
How does the production request flow work?
The job submission endpoint responds with an identifier before verification completes. The client polls the status endpoint until it receives a terminal state.
The evidence upload follows a separate path. This keeps sensitive file data outside the job API payload.
- Save architecture.md.
- Confirm that the editor now shows the mapping table.
- Confirm that the numbered API contract appears below the table.
- Add the correctness invariants below the API contract:
## Correctness invariants
1. One completed idempotency key causes at most one verification side effect.
2. Status changes must follow the allowed transition graph.
3. Duplicate deliveries may add an audit event, but they cannot change a terminal result.
4. A persistent failure receives at most `MAX_ATTEMPTS` processing attempts and produces one dead-letter record.
What do these invariants protect?
The invariants define the behaviors that must remain true under duplicate delivery. They also bound the work caused by a persistent failure.
- Add the production privacy controls below the invariants:
## Privacy controls for a production design
- Classify full-body evidence as sensitive personal data at ingestion.
- Keep the Amazon S3 bucket private and grant least-privilege access to the verification worker.
- Require encrypted transport and use Amazon S3 encryption at rest.
- Apply an Amazon S3 lifecycle policy that expires evidence when the defined retention period ends.
- Keep status and provenance metadata separate from raw evidence.
- Present the badge as a limited confidence signal, not a guarantee of identity, safety, or measurement accuracy.
Why separate evidence from status?
Raw full-body evidence needs stricter access controls than job metadata. Separating the two lets the system delete evidence while retaining limited audit provenance.
- Save architecture.md.
- Confirm that four numbered invariants appear below the API contract.
- Confirm that six privacy controls appear below the invariants.
- Add the measurement limitation at the end of architecture.md.
## Measurement limitation
The local formula assumes the person and the known-size reference are measured in the same image plane with comparable perspective. A production measurement system would need controlled capture, calibrated camera geometry, or additional depth information. An unconstrained ordinary photo does not establish trustworthy absolute scale.
Why state the measurement limitation?
The pixel ratio only has meaning under a controlled reference setup. The note keeps the verification result framed as a demo confidence signal.
✔️ Awesome, I've got everything!
Your architecture notes now cover the production mapping, API contract, correctness rules, privacy controls, and measurement boundary.
ⓧ I'd like to double check the full code
# HeightCheck Architecture Notes
## Scope
This is a Tinder-inspired learning project. It is not affiliated with Tinder and does not reproduce Tinder's undocumented internal architecture. The verification result is a confidence signal, not proof of height.
## Local to AWS mapping
| Local component | AWS production counterpart | Responsibility |
| --- | --- | --- |
| Browser form | Web or mobile client | Collect claim and controlled-reference evidence |
| Node.js HTTP routes | Amazon API Gateway plus AWS Lambda | Validate requests, create jobs, and return status |
| Evidence fields in memory | Private Amazon S3 object uploaded through a presigned URL | Store sensitive evidence outside the API payload |
| `jobs` Map | Amazon DynamoDB | Store job state, idempotency key, result, and audit metadata |
| `queue` array | Amazon SQS standard queue | Decouple job acceptance from verification work |
| `processMessage` | AWS Lambda consumer | Run verification and update job state |
| `deadLetterQueue` array | Amazon SQS dead-letter queue | Isolate messages that exhaust retries |
## API contract
1. `POST /jobs` validates metadata, creates a job, returns its identifier, and sends a queue message.
2. The client polls `GET /jobs/{jobId}` until the job reaches a terminal state.
3. In production, the client first requests a short-lived Amazon S3 presigned upload URL, uploads evidence directly, and submits the resulting object key with the job metadata.
## Correctness invariants
1. One completed idempotency key causes at most one verification side effect.
2. Status changes must follow the allowed transition graph.
3. Duplicate deliveries may add an audit event, but they cannot change a terminal result.
4. A persistent failure receives at most `MAX_ATTEMPTS` processing attempts and produces one dead-letter record.
## Privacy controls for a production design
- Classify full-body evidence as sensitive personal data at ingestion.
- Keep the Amazon S3 bucket private and grant least-privilege access to the verification worker.
- Require encrypted transport and use Amazon S3 encryption at rest.
- Apply an Amazon S3 lifecycle policy that expires evidence when the defined retention period ends.
- Keep status and provenance metadata separate from raw evidence.
- Present the badge as a limited confidence signal, not a guarantee of identity, safety, or measurement accuracy.
## Measurement limitation
The local formula assumes the person and the known-size reference are measured in the same image plane with comparable perspective. A production measurement system would need controlled capture, calibrated camera geometry, or additional depth information. An unconstrained ordinary photo does not establish trustworthy absolute scale.
- Save architecture.md.
- Switch back to the HeightCheck tab in Safari.
- Select Simulate verifier outage.
- Confirm that Deliver the same message twice remains selected.
Before you submit the final check, do you expect the duplicate delivery to increase the retry count beyond the configured limit?
- Click Submit async job.
You will see exactly three attempts. The job ends with a failed status.
The process count remains 3. The DLQ count becomes 1.
- Open a new tab in Safari.
- Enter http://localhost:3000/api/dlq in the address bar.
You will see a count of 1 with one failed message. Duplicate delivery has not created an extra dead-letter record.
You now have a bounded failure path with an inspectable terminal destination. Your local pipeline also has an interview-ready AWS production mapping.
Secret mission
Secret Mission: Prove the Pipeline Under a Duplicate Storm
Stress HeightCheck with a burst of duplicated jobs. Use the audit histories to prove that successful keys cause one side effect, failed work follows guarded transitions, and exhausted retries create one dead-letter record.
Clean Up Your Resources
Clean Up Your Resources
Choose what happens to your local lab next. This project stays on your Mac with no AWS resources or ongoing costs.
Resources you used:
- Local Node.js server process from server.js. Its memory contains the current job histories and dead-letter record.
- Local height-check-pipeline folder containing server.js, public/, and architecture.md.
Keep everything running
No action is needed. Choose this if you want to keep testing the completed pipeline or reviewing its audit histories.
- Leave the Node.js server process running while you inspect the duplicate-storm results.
- Keep the height-check-pipeline folder as a reference for asynchronous processing and idempotency.
The current jobs and dead-letter record remain available only while this server process stays active.
Pause - I'll come back to this later
Stop the local server to free its port. Your completed files remain ready for another session.
- Return to the integrated terminal in Visual Studio Code where the server is running.
- Press Control+C to stop the server.
You'll see the terminal prompt return. That leaves the lab safely paused with every file intact.
Delete - I don't want to use this again
Remove the local server state and every project file. This leaves your installed tools untouched.
Deletion scope
Deleting the folder permanently removes server.js, public/, architecture.md, and your reliability review. Your Mac and installed applications remain unchanged.
- Return to the integrated terminal where the server is running.
- Press Control+C to stop the server.
- Delete the height-check-pipeline folder by running these commands:
cd ..
rm -rf height-check-pipeline
What do these commands do?
The first line moves the terminal to the folder that contains height-check-pipeline. This gives the removal command a clear target.
The second line permanently removes the project folder and everything inside it.
- Use Finder to return to the location that contained height-check-pipeline.
- Confirm that the height-check-pipeline folder is no longer listed.
Still see the project folder?
- Confirm that the server process stopped before rerunning the removal command.
- Confirm that the terminal prompt moved to the folder containing height-check-pipeline.
Help me diagnose this cleanup.
That clears the complete local lab from your Mac. No cloud cleanup remains.
Nice Work!
Nice Work!
Outstanding work! Your HeightCheck lab is now a resilient asynchronous pipeline that keeps repeated deliveries from changing a completed outcome.
What you learned:
- Built a controlled-reference height check that turns constrained photo measurements into a visible confidence signal. Measured the user-facing delay created by synchronous processing.
- Replaced the blocking flow with an asynchronous job API. Polled each job through visible status changes. Used idempotency keys to recognize repeated logical work. Used guarded state transitions to protect terminal results.
- Bounded persistent failures with exponential backoff across exactly three attempts. Moved exhausted failures to a dead-letter queue. Documented an AWS architecture mapping for the local design. Added privacy-aware evidence controls for production handling.
- Secret Mission: Stress-tested the pipeline under a duplicate storm. Proved that every successful job kept processCount: 1. Confirmed that each audit included one harmless duplicate_skipped event. Verified that the forced-failure job stopped after three attempts with one dead-letter record.
Ready to quiz yourself?