Screenshot Alert System Design Lab
Build a browser lab to test durable screenshot alerts and notification retries.
Introduction
30 Second Summary
A screenshot alert feels instant when every service is healthy. A brief notification outage can make that alert disappear without leaving any record to recover.
In this project, you will build a two-phone browser simulator in CodePen that contrasts synchronous coupling with durable asynchronous messaging. You will use Excalidraw to turn those results into a production architecture you can defend in an interview.
What You'll Build
Your finished lab shows a simulated screenshot surviving an outage in durable history while notification delivery catches up after recovery.
By the end of this project, you'll have:
- A two-phone failure demo where disabled notification delivery causes the synchronous path to lose an alert.
- A durable recovery demo where one event remains in conversation history after notification failure. A duplicate is ignored through idempotency. The notification arrives after a retry.
- An interview-ready architecture diagram that traces one event through conversation-based partitioning into durable conversation history. The diagram shows notification fan-out. It places observability beside dead-letter handling.
- Secret Mission: Stress one conversation partition with a burst of events. Design a hot-key mitigation that makes its ordering trade-off explicit.
Are there any prerequisites?
You need a Mac with a web browser plus a free CodePen account. You should be comfortable with distributed systems concepts plus basic HTML, CSS, and JavaScript reading.
Before We Start
This opening checkpoint commits you to a browser-only screenshot-alert reliability lab. Its scenario follows viewer Jordan's simulated screenshot to sender Avery or Riley while durability preserves the event through notification failure.
Set Up the Browser Workspace
The screenshot-alert prototype needs a zero-install workspace that shows each simulated event immediately. A separate drawing surface gives the production architecture its own home.
You will use CodePen to run the browser prototype. You will use Excalidraw to map the production system later.
In this step, get ready to:
- Create or access a free CodePen account.
- Verify a public Pen through its live preview.
- Load the Excalidraw drawing canvas.
Prepare your CodePen Pen
CodePen keeps the project files beside a browser preview. This short feedback loop suits a dependency-free prototype.
Why Use a Browser-Only Workspace?
A full development environment would add installation work without improving this three-file prototype. CodePen keeps your attention on the reliability model.
A formal diagramming suite would add controls that this interview-style design does not need. Excalidraw keeps the architecture quick to sketch.
Choose the path that matches your current CodePen account state.
I Have a CodePen Account
- Visit the new Pen page in your browser.
- Complete CodePen's sign-in flow if the login page appears.
- Confirm that the new Pen exposes index.html, style.css, and script.js.
I Need a CodePen Account
- Visit the official CodePen Free Sign Up page.
- Create your account by following the on-screen prompts.
- Visit the new Pen page after your sign-in finishes.
- Confirm that the new Pen exposes index.html, style.css, and script.js.
That account hurdle is cleared. Your public Pen now gives the simulator a place to run.
Keep Public Pens Synthetic
Free Pens are public by default. Keep real messages out of this Pen.
Keep user identifiers and production data out too. Fictional test data protects everyone represented in the simulation.
Can't Open a New Pen?
- Confirm that your CodePen sign-in completed in the same browser.
- Reload the new Pen page after your account page shows that you are signed in.
Still blocked? Help me troubleshoot why CodePen does not open a new Pen.
Verify the Pen preview
A Pen separates the prototype into HTML, CSS, and JavaScript editors. A single heading gives you a visible signal that the HTML editor reaches the preview.
- Select the index.html editor in CodePen.
- Confirm that the style.css editor contains no text.
- Confirm that the script.js editor contains no text.
- Replace the contents of index.html with this heading:
<h1>Pipeline Lab Ready</h1>
What Does This Code Do?
- The h1 element creates the page's main heading.
- The Pipeline Lab Ready text gives you an unmistakable preview signal.
Before you check the preview, what text do you expect the browser to display?
- Save the Pen by clicking Save.
- Check the Preview area.
You should see Pipeline Lab Ready as a large heading. That result proves the HTML editor is connected to the browser preview.
Your first visible result is working. The Pen can now turn project code into something you can demonstrate.
Preview Still Blank?
- Confirm that index.html contains the complete heading line.
- Save the Pen again after correcting the HTML.
- Reload the browser tab if the preview does not update.
Need another pair of eyes? Help me find why the readiness heading is missing.
✔️ My Preview Matches
Your saved Pen shows the readiness heading. Keep this Pen open for the final workspace check.
- Keep the current contents of all three editors unchanged.
ⓧ I'd Like to Double Check the Full Code
- Compare index.html with this complete file:
<h1>Pipeline Lab Ready</h1>
Why the File Stops Here
The setup checkpoint needs only one heading. This keeps the first preview test focused on the connection between the HTML editor and the preview.
- Confirm that script.js contains no text.
- Confirm that style.css contains no text.
The readiness heading needs no browser behavior or custom styling. Empty JavaScript and CSS editors are the correct file states for this checkpoint.
Open the Excalidraw canvas
The Pen demonstrates system behavior. Excalidraw gives you a separate surface for the production design without requiring another account.
- Open a new browser tab.
- Visit https://excalidraw.com/.
You should see the Excalidraw drawing canvas. That canvas is where you will map the production architecture later.
Before you check both tabs, what two visible signs would prove that the workspace is ready?
- Switch back to the CodePen tab.
- Confirm that the Preview displays Pipeline Lab Ready.
- Return to the Excalidraw tab.
- Confirm that the drawing canvas is visible.
You should see the readiness heading in CodePen. You should also see the Excalidraw drawing canvas in the other tab.
Both browser surfaces are ready. You now have a live prototype workspace plus a canvas for defending the production design.
Your browser workspace is ready. Next up, you will replace the readiness heading with the two-phone synchronous simulator.
Build the Naive Synchronous Alert
Your browser workspace is ready. Now the CodePen preview needs a working screenshot-alert path that you can run from the viewer device to the sender device.
This first design uses synchronous coupling. The capture request calls notification delivery directly. A healthy run gives you an early visible win before you test the dependency under failure.
In this step, get ready to:
- Build the viewer interface, sender interface, controls, pipeline stages, metrics, operations panels, and activity log.
- Style the simulator so each stage and result is easy to follow during a demonstration.
- Create screenshot events and deliver a notification through the naive synchronous path.
Build the simulator interface
The interface gives each system responsibility a visible place. The viewer starts the event while the sender panel shows whether durable history or notification delivery changed.
- Switch back to the public Pen from the previous step.
- Select the HTML editor that currently contains <h1>Pipeline Lab Ready</h1>.
- Replace the existing HTML with the full index.html reference in the second tab below.
✔️ Awesome, I've got everything!
Confirm that the HTML editor begins with <!doctype html> and ends with the script.js reference.
ⓧ 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>Screenshot Alert Pipeline Lab</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header class="hero">
<p class="eyebrow">Distributed systems prototype</p>
<h1>Screenshot Alert Pipeline Lab</h1>
<p>Compare a fragile direct call with durable, idempotent event processing.</p>
</header>
<section class="control-panel" aria-label="Simulation controls">
<label>
Design
<select id="designMode">
<option value="sync">Naive synchronous</option>
<option value="async">Durable asynchronous</option>
</select>
</label>
<label>
Conversation
<select id="conversationSelect">
<option value="conversation-42">conversation-42</option>
<option value="conversation-77">conversation-77</option>
</select>
</label>
<label class="check-row">
<input id="duplicateDelivery" type="checkbox">
Deliver a duplicate event
</label>
<label class="check-row">
<input id="notificationHealthy" type="checkbox" checked>
Notification service available
</label>
<button id="retryButton" class="secondary" type="button">Retry pending</button>
<button id="resetButton" class="secondary" type="button">Reset lab</button>
</section>
<p id="modeDescription" class="mode-description"></p>
<main>
<section class="phone-grid" aria-label="User experience">
<article class="phone">
<div class="phone-top">Viewer device</div>
<div class="message-card">
<span class="message-label">Ephemeral message</span>
<p>This design disappears after viewing.</p>
<small>Viewer: Jordan</small>
</div>
<button id="captureButton" class="primary" type="button">Simulate screenshot</button>
</article>
<article class="phone">
<div class="phone-top">Sender device</div>
<div id="deliveryStatus" class="delivery-status" aria-live="polite"></div>
<h2>Durable conversation history</h2>
<ul id="senderHistory" class="history-list"></ul>
<h2>Push-style notifications</h2>
<ul id="notificationFeed" class="history-list"></ul>
</article>
</section>
<section class="pipeline" aria-label="Event pipeline">
<div class="stage" data-stage="device"><strong>1. Device signal</strong><span>Simulated OS callback</span></div>
<div class="stage" data-stage="api"><strong>2. Event API</strong><span>Authenticate and accept</span></div>
<div class="stage" data-stage="broker"><strong>3. Durable stream</strong><span>Partition by conversation</span></div>
<div class="stage" data-stage="worker"><strong>4. Event worker</strong><span>Check idempotency</span></div>
<div class="stage" data-stage="store"><strong>5. History store</strong><span>Persist once</span></div>
<div class="stage" data-stage="notify"><strong>6. Notification</strong><span>Deliver or retry</span></div>
</section>
<section class="metrics" aria-label="Metrics">
<div><span id="capturesMetric">0</span><small>Captures</small></div>
<div><span id="receivedMetric">0</span><small>Deliveries received</small></div>
<div><span id="persistedMetric">0</span><small>Events persisted</small></div>
<div><span id="duplicatesMetric">0</span><small>Duplicates ignored</small></div>
<div><span id="notificationsMetric">0</span><small>Notifications sent</small></div>
</section>
<section class="operations-grid">
<article class="panel">
<h2>Conversation partitions</h2>
<div id="partitionView"></div>
</article>
<article class="panel">
<h2>Notification retry queue</h2>
<div id="retryView"></div>
</article>
</section>
<section class="panel">
<h2>Activity log</h2>
<ol id="eventLog" class="event-log" aria-live="polite"></ol>
</section>
</main>
<script src="./script.js"></script>
</body>
</html>
How is the interface organized?
- The control panel selects the design mode and conversation used for the next capture.
- The two phone panels separate the viewer action from the sender-visible results.
- The six stages trace the route from a device signal to notification delivery.
- The metrics and operations panels expose state that would otherwise remain hidden inside the simulator.
- Save the Pen with the Save button.
- Check the Preview panel for the lab heading, two phone panels, six pipeline stages, five metrics, two operations panels, and the activity log.
You can now see the complete simulator structure. The page is unstyled because the CSS editor is still empty.
Missing part of the interface?
- Confirm that you replaced the temporary heading instead of pasting the new HTML below it.
- Check that the final closing tags and the script.js reference are present at the bottom of index.html.
Still stuck? Help me compare my CodePen HTML with the Screenshot Alert Pipeline Lab interface.
Style the reliability lab
A reliability demo needs clear visual states. The stylesheet separates the phones, pipeline, metrics, queues, and log while reserving distinct colors for successful work, warnings, and failures.
- Select the empty CSS editor in the same Pen.
- Paste the full style.css reference from the second tab below.
✔️ Awesome, I've got everything!
Confirm that the CSS begins with :root and ends with the mobile layout rules.
ⓧ 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: #07111f;
color: #e8f0ff;
--panel: #0d1b2d;
--panel-2: #12243a;
--line: #28415e;
--accent: #65d6ff;
--good: #56e39f;
--warn: #ffcc66;
--bad: #ff6b7a;
--muted: #91a5bd;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(101, 214, 255, 0.12), transparent 28rem),
#07111f;
}
button,
select,
input {
font: inherit;
}
.hero,
main,
.control-panel,
.mode-description {
width: min(1180px, calc(100% - 32px));
margin-inline: auto;
}
.hero {
padding: 36px 0 20px;
}
.hero h1 {
margin: 4px 0 8px;
font-size: clamp(2rem, 5vw, 3.5rem);
}
.hero p {
color: var(--muted);
margin: 0;
}
.eyebrow {
color: var(--accent) !important;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.78rem;
font-weight: 800;
}
.control-panel {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
padding: 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(13, 27, 45, 0.94);
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
}
.check-row {
display: flex;
align-items: center;
min-height: 42px;
padding-inline: 8px;
}
select,
button {
min-height: 42px;
border: 1px solid var(--line);
border-radius: 10px;
color: #f5f9ff;
background: #102238;
}
select {
padding: 0 34px 0 12px;
}
button {
cursor: pointer;
padding: 0 16px;
font-weight: 800;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.primary {
width: 100%;
border-color: #38bde8;
background: linear-gradient(135deg, #0da8d5, #3478f6);
}
.secondary:hover,
.primary:hover {
filter: brightness(1.12);
}
.mode-description {
margin-top: 12px;
color: var(--warn);
}
main {
display: grid;
gap: 18px;
padding: 14px 0 48px;
}
.phone-grid,
.operations-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.phone,
.panel {
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
padding: 18px;
}
.phone {
min-height: 300px;
}
.phone-top {
margin-bottom: 16px;
color: var(--accent);
font-weight: 900;
letter-spacing: 0.05em;
}
.message-card {
margin-bottom: 18px;
padding: 24px;
border: 1px solid #4d6785;
border-radius: 16px;
background: #1a2e47;
}
.message-label {
color: var(--warn);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.phone h2,
.panel h2 {
font-size: 1rem;
margin: 16px 0 10px;
}
.delivery-status {
min-height: 44px;
border-radius: 10px;
padding: 12px;
background: #0a1625;
color: var(--muted);
}
.delivery-status.success {
color: var(--good);
border: 1px solid rgba(86, 227, 159, 0.5);
}
.delivery-status.warning {
color: var(--warn);
border: 1px solid rgba(255, 204, 102, 0.5);
}
.delivery-status.error {
color: var(--bad);
border: 1px solid rgba(255, 107, 122, 0.5);
}
.pipeline {
display: grid;
grid-template-columns: repeat(6, minmax(130px, 1fr));
gap: 10px;
overflow-x: auto;
}
.stage {
min-height: 110px;
display: grid;
align-content: center;
gap: 8px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #0d1b2d;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.stage span {
color: var(--muted);
font-size: 0.82rem;
}
.stage.active {
transform: translateY(-4px);
border-color: var(--accent);
background: #12334a;
}
.stage.success {
border-color: var(--good);
}
.stage.error {
border-color: var(--bad);
background: #3a1723;
}
body[data-mode="sync"] .stage[data-stage="broker"],
body[data-mode="sync"] .stage[data-stage="worker"],
body[data-mode="sync"] .stage[data-stage="store"] {
opacity: 0.3;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 10px;
}
.metrics div {
padding: 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: #0d1b2d;
}
.metrics span {
display: block;
color: var(--accent);
font-size: 1.8rem;
font-weight: 900;
}
.metrics small {
color: var(--muted);
}
.history-list,
.event-log {
margin: 0;
padding-left: 20px;
}
.history-list li,
.event-log li {
margin-block: 8px;
color: #cedaf0;
}
.partition-row,
.retry-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-block: 8px;
padding: 10px 12px;
border-radius: 10px;
background: #091725;
}
.partition-key {
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.empty {
color: var(--muted);
font-style: italic;
}
.log-time {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.log-good {
color: var(--good);
}
.log-warn {
color: var(--warn);
}
.log-bad {
color: var(--bad);
}
@media (max-width: 850px) {
.phone-grid,
.operations-grid {
grid-template-columns: 1fr;
}
.metrics {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
@media (max-width: 520px) {
.hero,
main,
.control-panel,
.mode-description {
width: min(100% - 20px, 1180px);
}
.control-panel > * {
width: 100%;
}
.metrics {
grid-template-columns: 1fr;
}
}
What does this stylesheet do?
- The theme variables keep the success, warning, failure, panel, and text colors consistent.
- The grid rules place the phones and operations panels side by side on wider screens.
- The stage states provide visible movement and color changes while an event travels through the path.
- The synchronous-mode rule dims the durable stream, worker, and history-store stages because the direct path bypasses them.
- Save the Pen with the Save button.
- Check the Preview panel for a dark interface with two phone cards, blue controls, six boxed stages, metric cards, and operations panels.
- Narrow the browser window until the phone cards stack vertically.
The simulator now reads like an operational dashboard. Its responsive layout also keeps the complete path usable on a narrow screen.
Still seeing an unstyled page?
- Confirm that the CSS is in the style.css editor.
- Check that index.html contains the stylesheet reference near the top.
- Look for a missing closing brace at the bottom of the mobile layout rules.
Need another pair of eyes? Help me find why my Screenshot Alert Pipeline Lab CSS is not appearing in CodePen.
Wire the synchronous capture path
The JavaScript creates a screenshot event with identity, conversation, routing, sender, viewer, and timestamp fields. Its conversationId also becomes the intended partition key shown in the operations panel.
For this first mode, runSynchronousPath() sends each delivery straight to the notification function. The durable stream, event worker, history store, idempotency check, and retry processing remain outside the active path.
- Select the empty JavaScript editor in the same Pen.
- Paste the complete synchronous script.js reference from the second tab below.
✔️ Awesome, I've got everything!
Confirm that script.js ends with resetLab(); after the event listeners.
ⓧ I'd like to double check the full code
const ui = {
designMode: document.querySelector("#designMode"),
conversationSelect: document.querySelector("#conversationSelect"),
duplicateDelivery: document.querySelector("#duplicateDelivery"),
notificationHealthy: document.querySelector("#notificationHealthy"),
captureButton: document.querySelector("#captureButton"),
retryButton: document.querySelector("#retryButton"),
resetButton: document.querySelector("#resetButton"),
modeDescription: document.querySelector("#modeDescription"),
deliveryStatus: document.querySelector("#deliveryStatus"),
senderHistory: document.querySelector("#senderHistory"),
notificationFeed: document.querySelector("#notificationFeed"),
partitionView: document.querySelector("#partitionView"),
retryView: document.querySelector("#retryView"),
eventLog: document.querySelector("#eventLog"),
capturesMetric: document.querySelector("#capturesMetric"),
receivedMetric: document.querySelector("#receivedMetric"),
persistedMetric: document.querySelector("#persistedMetric"),
duplicatesMetric: document.querySelector("#duplicatesMetric"),
notificationsMetric: document.querySelector("#notificationsMetric")
};
const conversationNames = {
"conversation-42": "Avery and Jordan",
"conversation-77": "Riley and Jordan"
};
let state;
function currentTime() {
return new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
function createInitialState() {
return {
eventCounter: 0,
partitions: {
"conversation-42": [],
"conversation-77": []
},
retryQueue: [],
processedEventIds: new Set(),
history: [],
notifications: [],
logs: [
{
time: currentTime(),
message: "Lab reset. Start with the naive synchronous design.",
tone: "info"
}
],
statusMessage: "No screenshot event has been processed yet.",
statusType: "info",
metrics: {
captures: 0,
received: 0,
persisted: 0,
duplicatesIgnored: 0,
notificationsSent: 0
}
};
}
function wait(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function clearStageStates() {
document.querySelectorAll(".stage").forEach((stage) => {
stage.classList.remove("active", "success", "error");
});
}
async function pulseStage(stageName, outcome = "success") {
const stage = document.querySelector(`[data-stage="${stageName}"]`);
stage.classList.remove("success", "error");
stage.classList.add("active");
await wait(220);
stage.classList.remove("active");
stage.classList.add(outcome);
}
function addLog(message, tone = "info") {
state.logs.unshift({ time: currentTime(), message, tone });
state.logs = state.logs.slice(0, 18);
renderLogs();
}
function createScreenshotEvent() {
state.eventCounter += 1;
const conversationId = ui.conversationSelect.value;
return {
eventId: `capture-${String(state.eventCounter).padStart(3, "0")}`,
type: "conversation.screenshot_captured",
conversationId,
partitionKey: conversationId,
viewerId: "viewer-jordan",
senderId: conversationId === "conversation-42" ? "sender-avery" : "sender-riley",
capturedAt: currentTime()
};
}
function setStatus(message, type = "info") {
state.statusMessage = message;
state.statusType = type;
renderStatus();
}
function renderStatus() {
ui.deliveryStatus.textContent = state.statusMessage;
ui.deliveryStatus.className = `delivery-status ${state.statusType}`;
}
function renderMode() {
const asynchronous = ui.designMode.value === "async";
document.body.dataset.mode = asynchronous ? "async" : "sync";
ui.modeDescription.textContent = asynchronous
? "Durable path: accept, enqueue by conversation, deduplicate, persist, then notify or retry."
: "Naive path: the request calls notification delivery directly, with no durable recovery point.";
}
function renderMetrics() {
ui.capturesMetric.textContent = state.metrics.captures;
ui.receivedMetric.textContent = state.metrics.received;
ui.persistedMetric.textContent = state.metrics.persisted;
ui.duplicatesMetric.textContent = state.metrics.duplicatesIgnored;
ui.notificationsMetric.textContent = state.metrics.notificationsSent;
}
function renderHistory() {
if (state.history.length === 0) {
ui.senderHistory.innerHTML = '<li class="empty">No durable screenshot events</li>';
} else {
ui.senderHistory.innerHTML = state.history
.map(
(event) => `
<li>
<strong>${conversationNames[event.conversationId]}</strong><br>
Screenshot recorded at ${event.capturedAt}<br>
<small>${event.eventId}</small>
</li>`
)
.join("");
}
if (state.notifications.length === 0) {
ui.notificationFeed.innerHTML = '<li class="empty">No notifications delivered</li>';
} else {
ui.notificationFeed.innerHTML = state.notifications
.map(
(event) => `
<li>
Alert sent for ${event.conversationId}<br>
<small>${event.eventId}</small>
</li>`
)
.join("");
}
}
function renderPartitions() {
ui.partitionView.innerHTML = Object.entries(state.partitions)
.map(
([partitionKey, events]) => `
<div class="partition-row">
<span class="partition-key">${partitionKey}</span>
<span>${events.length} queued</span>
</div>`
)
.join("");
}
function renderRetries() {
if (state.retryQueue.length === 0) {
ui.retryView.innerHTML = '<p class="empty">No pending notifications</p>';
return;
}
ui.retryView.innerHTML = state.retryQueue
.map(
(item) => `
<div class="retry-row">
<span>${item.event.eventId}</span>
<span>attempts: ${item.attempts}</span>
</div>`
)
.join("");
}
function renderLogs() {
ui.eventLog.innerHTML = state.logs
.map(
(entry) => `
<li class="log-${entry.tone}">
<span class="log-time">${entry.time}</span> ${entry.message}
</li>`
)
.join("");
}
function render() {
renderMode();
renderStatus();
renderMetrics();
renderHistory();
renderPartitions();
renderRetries();
renderLogs();
}
function deliverNotification(event) {
state.notifications.unshift(event);
state.metrics.notificationsSent += 1;
addLog(`Notification delivered for ${event.eventId}.`, "good");
}
async function runSynchronousPath(event) {
const deliveryCount = ui.duplicateDelivery.checked ? 2 : 1;
for (let index = 0; index < deliveryCount; index += 1) {
state.metrics.received += 1;
if (index > 0) {
addLog(`Duplicate ${event.eventId} entered the direct path with no idempotency check.`, "warn");
}
if (ui.notificationHealthy.checked) {
await pulseStage("notify", "success");
deliverNotification(event);
setStatus("Notification delivered, but no durable conversation event exists.", "warning");
} else {
await pulseStage("notify", "error");
addLog(`Notification failed for ${event.eventId}. The synchronous alert was lost.`, "bad");
setStatus("Alert lost. There is no durable history or retry record.", "error");
}
}
}
async function simulateCapture() {
ui.captureButton.disabled = true;
clearStageStates();
const event = createScreenshotEvent();
state.metrics.captures += 1;
addLog(`Device produced ${event.eventId} for ${event.conversationId}.`, "info");
await pulseStage("device", "success");
await pulseStage("api", "success");
await runSynchronousPath(event);
render();
ui.captureButton.disabled = false;
}
function resetLab() {
state = createInitialState();
clearStageStates();
render();
}
ui.captureButton.addEventListener("click", simulateCapture);
ui.resetButton.addEventListener("click", resetLab);
ui.designMode.addEventListener("change", renderMode);
ui.notificationHealthy.addEventListener("change", () => {
addLog(
ui.notificationHealthy.checked
? "Notification service marked available."
: "Notification service marked unavailable.",
ui.notificationHealthy.checked ? "good" : "warn"
);
});
resetLab();
How does the synchronous path work?
- The ui map gives the script one reference to every control, metric, panel, and log surface.
- The createScreenshotEvent() function creates the project-defined event payload for the selected conversation.
- The runSynchronousPath() function increments received deliveries before calling notification delivery directly.
- The render helpers turn the current state into visible metrics, history, partition rows, retry state, and activity entries.
- Save the Pen with the Save button.
- Check that the Preview panel shows No screenshot event has been processed yet. in the sender status area.
- Check that both conversation partitions display 0 queued.
- Check that the retry panel displays No pending notifications.
- Check that the activity log begins with the lab reset entry.
Before you trigger the healthy path, which metrics do you expect a direct notification call to change?
- Select Naive synchronous in the Design menu.
- Select conversation-42 in the Conversation menu.
- Leave Deliver a duplicate event off.
- Keep Notification service available on.
- Click Simulate screenshot.
You should see the device, API, and notification stages light up. The capture, received, and notification metrics each increase to 1.
The sender notification feed shows capture-001 for conversation-42. Durable conversation history stays empty because the direct path never writes to it.
Read the event payload
The activity log identifies capture-001 and conversation-42 for this run. The createScreenshotEvent() object also contains type, partitionKey, viewerId, senderId, and capturedAt.
The operations panel displays both conversation IDs as intended routing keys. Their queue depths remain at zero because this mode bypasses the durable stream.
Capture button not responding?
- Confirm that the bottom of index.html loads script.js.
- Check that captureButton maps to #captureButton near the top of the JavaScript.
- Confirm that resetLab(); remains at the bottom of script.js.
Need help tracing it? Help me debug why the synchronous capture button does not update my CodePen simulator.
Your direct alert path is working. Next, you will put its notification dependency under pressure and observe what the current design can recover.
Break the Synchronous Design
Your healthy capture proved that the viewer can trigger a sender alert through the direct path. Now you will test whether that success survives a dependency outage.
The request still owns notification delivery. This synchronous coupling makes the screenshot alert depend on a second service.
The outage test exposes what survives when that direct call fails. A duplicate delivery then exposes whether the path can recognize an event it has already handled.
In this step, get ready to:
- Force a notification outage through the naive synchronous path.
- Repeat a capture with duplicate delivery enabled.
- Connect the visible evidence to the design's reliability gaps.
Force the notification outage
Notification delivery sits inside the synchronous request path. Its availability therefore decides whether the screenshot alert succeeds.
- Switch back to your CodePen Pen from earlier.
- Select Naive synchronous in the Design control.
- Clear Deliver a duplicate event.
- Turn off Notification service available.
Before the click, predict whether any durable record can survive a notification failure.
- Trigger the outage test by clicking Simulate screenshot.
You'll see the notification stage turn red. The Notifications sent counter does not increase.
Durable conversation history remains empty.
The Notification retry queue continues to show No pending notifications. The activity log reports that the synchronous alert was lost.
This Is the Intended Failure
The red stage exposes the coupling fault. The request has no durable recovery point after notification delivery fails.
The missing retry record means no later process can recover this alert.
Stage Stays Green?
- Confirm that Naive synchronous is selected in Design.
- Confirm that Notification service available is turned off.
- Trigger a fresh event with Simulate screenshot.
Help me debug why the notification stage does not turn red.
You found the first fault: a notification outage erases the alert.
Expose duplicate side effects
Duplicate delivery tests whether repeated event IDs represent one logical event. The outcome depends on whether the path checks what it has already handled.
- Keep Naive synchronous selected in Design.
- Leave Notification service available turned off.
- Turn on Deliver a duplicate event.
Before the next click, predict whether the repeated event ID stops before notification or reaches the stage twice.
- Trigger the duplicate test by clicking Simulate screenshot.
The Captures counter rises by one. The Deliveries received counter rises by two.
The activity log shows the duplicate eventId entering the direct path. It also shows two failed notification attempts for that same event.
Why the Duplicate Matters
An idempotency check lets a consumer recognize that it has already applied an event. Without that check, a retry can repeat the same side effect.
Both deliveries carry the same eventId. The direct path still treats each delivery as new work.
You have now seen one logical screenshot event attempt notification twice.
State the reliability gap
A reliable diagnosis connects each design claim to visible evidence. Your final check ties the red stage to the missing recovery state.
Before the final check, predict which surfaces still contain recoverable evidence of the failed alert.
- Inspect the notification stage for its final state.
- Inspect Durable conversation history for a persisted screenshot event.
- Inspect the Notification retry queue for pending work.
- Compare the duplicate entries in the Activity log.
The notification stage remains red. Durable conversation history remains empty.
The retry queue remains at zero. The log shows the duplicated event reaching the direct path twice.
That's the failure isolated. The direct request path cannot preserve a failed alert.
It also cannot identify a duplicate delivery.
The synchronous path has now failed in both ways the next design must address. Next, you'll make the event durable before notification delivery.
Add Durable Asynchronous Processing
Your last test exposed synchronous coupling in the CodePen simulator. The request path lost the alert because notification availability controlled the result.
Durable asynchronous messaging gives every accepted event a recovery point before notification delivery. In this step, you'll queue each event by conversation. You'll persist each event once before retrying notification delivery separately.
In this step, get ready to:
- Route screenshot events into conversation-specific queues.
- Persist each event once by rejecting duplicate event IDs.
- Retry failed notifications after the service recovers.
Route events through conversation queues
A durable event stream separates event acceptance from event processing. Your browser model represents that stream with one in-memory queue for each conversation.
- In script.js, find runSynchronousPath(event).
- Add enqueueEvent(event) directly below that function by copying this code:
function enqueueEvent(event) {
state.partitions[event.partitionKey].push({ ...event });
state.metrics.received += 1;
addLog(`${event.eventId} enqueued with partition key ${event.partitionKey}.`, "good");
}
What does this code do?
- The event's partitionKey selects the queue for its conversation.
- The spread syntax creates a copy before placing the event in the queue.
- The received metric counts each delivery attempt. A duplicate therefore counts as another delivery.
- The activity log records the selected partition key for the demonstration.
The worker must drain each queue while keeping events from the same conversation together. Its first responsibility is to reject an event ID that has already reached durable history.
- Add the first runnable version of drainPartitions() directly below enqueueEvent(event) by copying this code:
async function drainPartitions() {
for (const partitionKey of Object.keys(state.partitions)) {
const partition = state.partitions[partitionKey];
while (partition.length > 0) {
const event = partition.shift();
renderPartitions();
await pulseStage("worker", "success");
if (state.processedEventIds.has(event.eventId)) {
state.metrics.duplicatesIgnored += 1;
addLog(`Duplicate ${event.eventId} ignored by the idempotency check.`, "warn");
continue;
}
state.processedEventIds.add(event.eventId);
await pulseStage("store", "success");
state.history.unshift(event);
state.metrics.persisted += 1;
addLog(`${event.eventId} persisted once in conversation history.`, "good");
}
}
}
How does the worker protect history?
- Each conversation queue drains independently through the same worker logic.
- The processedEventIds set acts as the idempotency store.
- A known eventId increments duplicatesIgnored before the worker skips the remaining side effects.
- A new event reaches state.history before notification processing begins.
- Save script.js.
- Inspect the Pen preview after it reloads.
You should still see the complete simulator with both conversation queues listed. This confirms that the new worker code parses without breaking the preview.
Did the preview stop loading?
- Confirm that enqueueEvent(event) closes before drainPartitions() begins.
- Compare the braces around the duplicate check with the snippet above.
- Check the CodePen console for the first line that identifies a syntax problem.
Still stuck? Help me find the syntax problem in my queue and partition functions.
Persist once and activate async mode
At-least-once delivery can send the same event more than once. The idempotency check makes repeated delivery safe because only the first copy can reach history or notification handling.
Notification delivery now becomes a side effect of durable processing. A failed attempt adds a retry record while the persisted conversation event stays available.
- In drainPartitions(), find the log entry immediately after state.metrics.persisted += 1;.
- Add the notification decision directly below that log entry by copying this code:
if (ui.notificationHealthy.checked) {
await pulseStage("notify", "success");
deliverNotification(event);
} else {
await pulseStage("notify", "error");
state.retryQueue.push({ event, attempts: 1 });
addLog(`Notification failed for ${event.eventId}; durable history remains and retry was scheduled.`, "warn");
}
Why persist before notifying?
The history write finishes before the notification branch runs. An outage can delay the notification without erasing the screenshot event.
Each retry item stores the original event with an initial attempt count of 1. Durable history becomes the source of truth.
- At the end of drainPartitions(), find this current tail:
}
}
}
What is this reference showing?
These braces currently close the queue loop before closing drainPartitions(). The replacement adds a final status decision before the function closes.
- Replace that tail with this completed version:
}
}
if (state.retryQueue.length > 0) {
setStatus("History is durable. Notification delivery is eventually consistent and waiting for retry.", "warning");
} else {
setStatus("History and notification delivery are both complete.", "success");
}
}
What does the status reveal?
A non-empty retry queue means history and notification delivery temporarily disagree. That gap demonstrates eventual consistency directly in the sender panel.
An empty retry queue means durable history and push delivery have both completed.
- Save script.js.
- Inspect the Pen preview after it reloads.
You should see both phones and all six pipeline stages. The intact preview confirms that the completed worker function parses correctly.
The async entry point places the original delivery into its conversation queue. It also places the same event into that queue again when duplicate delivery is enabled.
- Add runAsynchronousPath(event) directly below drainPartitions() by copying this code:
async function runAsynchronousPath(event) {
enqueueEvent(event);
if (ui.duplicateDelivery.checked) {
enqueueEvent(event);
}
render();
await pulseStage("broker", "success");
await drainPartitions();
}
How does duplicate delivery enter the model?
- The first call to enqueueEvent(event) represents the original delivery.
- The optional second call uses the same event object. Its eventId therefore stays unchanged.
- The broker stage pulses after the queues render. The worker then drains every conversation partition.
- In simulateCapture(), find the current direct-path line:
await runSynchronousPath(event);
What does the current line do?
Every capture currently enters the synchronous function. The design selector does not control processing until this line branches on its value.
- Replace that line with this mode-aware branch:
if (ui.designMode.value === "sync") {
await runSynchronousPath(event);
} else {
await runAsynchronousPath(event);
}
How does the selector control the path?
The sync value preserves the earlier direct-call demonstration. Any other value sends the event through the partitioned async path.
- Save script.js.
Before you trigger the async path, do you think two deliveries with the same event ID create one history item or two?
- Set Design to Durable asynchronous.
- Click Reset lab.
- Set Conversation to conversation-42.
- Turn on Deliver a duplicate event.
- Turn off Notification service available.
- Click Simulate screenshot.
You should see one durable history item and one ignored duplicate. The notification retry panel should show one pending item with attempts: 1.
The activity log should show two deliveries for capture-001. It should also show one persistence entry followed by one duplicate rejection.
Seeing two history items?
- Confirm that state.processedEventIds.has(event.eventId) runs before the history write.
- Confirm that the duplicate branch ends with continue;.
- Check that both calls inside runAsynchronousPath(event) pass the same event.
Need another pair of eyes? Help me debug why duplicate screenshot events are being persisted twice.
Retry notification delivery
The retry queue isolates notification failure from durable conversation history. A retry during an outage increases the attempt count. A retry after recovery delivers each pending notification once.
- Add retryPendingNotifications() directly below simulateCapture() by copying this code:
async function retryPendingNotifications() {
if (state.retryQueue.length === 0) {
setStatus("There are no pending notifications to retry.", "info");
return;
}
ui.retryButton.disabled = true;
clearStageStates();
if (!ui.notificationHealthy.checked) {
await pulseStage("notify", "error");
state.retryQueue.forEach((item) => {
item.attempts += 1;
});
addLog("Retry attempted while the notification service was unavailable.", "bad");
setStatus("Retry failed. Durable history is safe and notifications remain pending.", "warning");
} else {
while (state.retryQueue.length > 0) {
const item = state.retryQueue.shift();
await pulseStage("notify", "success");
deliverNotification(item.event);
}
setStatus("Pending notifications delivered after recovery.", "success");
}
render();
ui.retryButton.disabled = false;
}
How does recovery work?
- An empty queue returns immediately with an informational status.
- An unavailable notification service increments every pending item's attempt count.
- An available service removes pending items one at a time. Each item passes through deliverNotification(event).
- The button stays disabled while the retry loop runs. This prevents overlapping manual retry attempts.
- At the bottom of script.js, find this event listener:
ui.captureButton.addEventListener("click", simulateCapture);
What is missing here?
The capture button already starts the simulation. The retry button still needs its own click handler before it can call the new recovery function.
- Replace that listener with these two listeners:
ui.captureButton.addEventListener("click", simulateCapture);
ui.retryButton.addEventListener("click", retryPendingNotifications);
What does the new listener connect?
The second listener connects Retry pending to retryPendingNotifications(). The UI can now replay notification work after a simulated recovery.
- Save script.js.
✔️ Awesome, I've got everything!
Great. Your async path now queues by conversation, persists once, ignores duplicate event IDs, and retries notification delivery.
ⓧ I'd like to double check the full code
Compare your script.js with this complete version:
const ui = {
designMode: document.querySelector("#designMode"),
conversationSelect: document.querySelector("#conversationSelect"),
duplicateDelivery: document.querySelector("#duplicateDelivery"),
notificationHealthy: document.querySelector("#notificationHealthy"),
captureButton: document.querySelector("#captureButton"),
retryButton: document.querySelector("#retryButton"),
resetButton: document.querySelector("#resetButton"),
modeDescription: document.querySelector("#modeDescription"),
deliveryStatus: document.querySelector("#deliveryStatus"),
senderHistory: document.querySelector("#senderHistory"),
notificationFeed: document.querySelector("#notificationFeed"),
partitionView: document.querySelector("#partitionView"),
retryView: document.querySelector("#retryView"),
eventLog: document.querySelector("#eventLog"),
capturesMetric: document.querySelector("#capturesMetric"),
receivedMetric: document.querySelector("#receivedMetric"),
persistedMetric: document.querySelector("#persistedMetric"),
duplicatesMetric: document.querySelector("#duplicatesMetric"),
notificationsMetric: document.querySelector("#notificationsMetric")
};
const conversationNames = {
"conversation-42": "Avery and Jordan",
"conversation-77": "Riley and Jordan"
};
let state;
function currentTime() {
return new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
function createInitialState() {
return {
eventCounter: 0,
partitions: {
"conversation-42": [],
"conversation-77": []
},
retryQueue: [],
processedEventIds: new Set(),
history: [],
notifications: [],
logs: [
{
time: currentTime(),
message: "Lab reset. Start with the naive synchronous design.",
tone: "info"
}
],
statusMessage: "No screenshot event has been processed yet.",
statusType: "info",
metrics: {
captures: 0,
received: 0,
persisted: 0,
duplicatesIgnored: 0,
notificationsSent: 0
}
};
}
function wait(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function clearStageStates() {
document.querySelectorAll(".stage").forEach((stage) => {
stage.classList.remove("active", "success", "error");
});
}
async function pulseStage(stageName, outcome = "success") {
const stage = document.querySelector(`[data-stage="${stageName}"]`);
stage.classList.remove("success", "error");
stage.classList.add("active");
await wait(220);
stage.classList.remove("active");
stage.classList.add(outcome);
}
function addLog(message, tone = "info") {
state.logs.unshift({ time: currentTime(), message, tone });
state.logs = state.logs.slice(0, 18);
renderLogs();
}
function createScreenshotEvent() {
state.eventCounter += 1;
const conversationId = ui.conversationSelect.value;
return {
eventId: `capture-${String(state.eventCounter).padStart(3, "0")}`,
type: "conversation.screenshot_captured",
conversationId,
partitionKey: conversationId,
viewerId: "viewer-jordan",
senderId: conversationId === "conversation-42" ? "sender-avery" : "sender-riley",
capturedAt: currentTime()
};
}
function setStatus(message, type = "info") {
state.statusMessage = message;
state.statusType = type;
renderStatus();
}
function renderStatus() {
ui.deliveryStatus.textContent = state.statusMessage;
ui.deliveryStatus.className = `delivery-status ${state.statusType}`;
}
function renderMode() {
const asynchronous = ui.designMode.value === "async";
document.body.dataset.mode = asynchronous ? "async" : "sync";
ui.modeDescription.textContent = asynchronous
? "Durable path: accept, enqueue by conversation, deduplicate, persist, then notify or retry."
: "Naive path: the request calls notification delivery directly, with no durable recovery point.";
}
function renderMetrics() {
ui.capturesMetric.textContent = state.metrics.captures;
ui.receivedMetric.textContent = state.metrics.received;
ui.persistedMetric.textContent = state.metrics.persisted;
ui.duplicatesMetric.textContent = state.metrics.duplicatesIgnored;
ui.notificationsMetric.textContent = state.metrics.notificationsSent;
}
function renderHistory() {
if (state.history.length === 0) {
ui.senderHistory.innerHTML = '<li class="empty">No durable screenshot events</li>';
} else {
ui.senderHistory.innerHTML = state.history
.map(
(event) => `
<li>
<strong>${conversationNames[event.conversationId]}</strong><br>
Screenshot recorded at ${event.capturedAt}<br>
<small>${event.eventId}</small>
</li>`
)
.join("");
}
if (state.notifications.length === 0) {
ui.notificationFeed.innerHTML = '<li class="empty">No notifications delivered</li>';
} else {
ui.notificationFeed.innerHTML = state.notifications
.map(
(event) => `
<li>
Alert sent for ${event.conversationId}<br>
<small>${event.eventId}</small>
</li>`
)
.join("");
}
}
function renderPartitions() {
ui.partitionView.innerHTML = Object.entries(state.partitions)
.map(
([partitionKey, events]) => `
<div class="partition-row">
<span class="partition-key">${partitionKey}</span>
<span>${events.length} queued</span>
</div>`
)
.join("");
}
function renderRetries() {
if (state.retryQueue.length === 0) {
ui.retryView.innerHTML = '<p class="empty">No pending notifications</p>';
return;
}
ui.retryView.innerHTML = state.retryQueue
.map(
(item) => `
<div class="retry-row">
<span>${item.event.eventId}</span>
<span>attempts: ${item.attempts}</span>
</div>`
)
.join("");
}
function renderLogs() {
ui.eventLog.innerHTML = state.logs
.map(
(entry) => `
<li class="log-${entry.tone}">
<span class="log-time">${entry.time}</span> ${entry.message}
</li>`
)
.join("");
}
function render() {
renderMode();
renderStatus();
renderMetrics();
renderHistory();
renderPartitions();
renderRetries();
renderLogs();
}
function deliverNotification(event) {
state.notifications.unshift(event);
state.metrics.notificationsSent += 1;
addLog(`Notification delivered for ${event.eventId}.`, "good");
}
async function runSynchronousPath(event) {
const deliveryCount = ui.duplicateDelivery.checked ? 2 : 1;
for (let index = 0; index < deliveryCount; index += 1) {
state.metrics.received += 1;
if (index > 0) {
addLog(`Duplicate ${event.eventId} entered the direct path with no idempotency check.`, "warn");
}
if (ui.notificationHealthy.checked) {
await pulseStage("notify", "success");
deliverNotification(event);
setStatus("Notification delivered, but no durable conversation event exists.", "warning");
} else {
await pulseStage("notify", "error");
addLog(`Notification failed for ${event.eventId}. The synchronous alert was lost.`, "bad");
setStatus("Alert lost. There is no durable history or retry record.", "error");
}
}
}
function enqueueEvent(event) {
state.partitions[event.partitionKey].push({ ...event });
state.metrics.received += 1;
addLog(`${event.eventId} enqueued with partition key ${event.partitionKey}.`, "good");
}
async function drainPartitions() {
for (const partitionKey of Object.keys(state.partitions)) {
const partition = state.partitions[partitionKey];
while (partition.length > 0) {
const event = partition.shift();
renderPartitions();
await pulseStage("worker", "success");
if (state.processedEventIds.has(event.eventId)) {
state.metrics.duplicatesIgnored += 1;
addLog(`Duplicate ${event.eventId} ignored by the idempotency check.`, "warn");
continue;
}
state.processedEventIds.add(event.eventId);
await pulseStage("store", "success");
state.history.unshift(event);
state.metrics.persisted += 1;
addLog(`${event.eventId} persisted once in conversation history.`, "good");
if (ui.notificationHealthy.checked) {
await pulseStage("notify", "success");
deliverNotification(event);
} else {
await pulseStage("notify", "error");
state.retryQueue.push({ event, attempts: 1 });
addLog(`Notification failed for ${event.eventId}; durable history remains and retry was scheduled.`, "warn");
}
}
}
if (state.retryQueue.length > 0) {
setStatus("History is durable. Notification delivery is eventually consistent and waiting for retry.", "warning");
} else {
setStatus("History and notification delivery are both complete.", "success");
}
}
async function runAsynchronousPath(event) {
enqueueEvent(event);
if (ui.duplicateDelivery.checked) {
enqueueEvent(event);
}
render();
await pulseStage("broker", "success");
await drainPartitions();
}
async function simulateCapture() {
ui.captureButton.disabled = true;
clearStageStates();
const event = createScreenshotEvent();
state.metrics.captures += 1;
addLog(`Device produced ${event.eventId} for ${event.conversationId}.`, "info");
await pulseStage("device", "success");
await pulseStage("api", "success");
if (ui.designMode.value === "sync") {
await runSynchronousPath(event);
} else {
await runAsynchronousPath(event);
}
render();
ui.captureButton.disabled = false;
}
async function retryPendingNotifications() {
if (state.retryQueue.length === 0) {
setStatus("There are no pending notifications to retry.", "info");
return;
}
ui.retryButton.disabled = true;
clearStageStates();
if (!ui.notificationHealthy.checked) {
await pulseStage("notify", "error");
state.retryQueue.forEach((item) => {
item.attempts += 1;
});
addLog("Retry attempted while the notification service was unavailable.", "bad");
setStatus("Retry failed. Durable history is safe and notifications remain pending.", "warning");
} else {
while (state.retryQueue.length > 0) {
const item = state.retryQueue.shift();
await pulseStage("notify", "success");
deliverNotification(item.event);
}
setStatus("Pending notifications delivered after recovery.", "success");
}
render();
ui.retryButton.disabled = false;
}
function resetLab() {
state = createInitialState();
clearStageStates();
render();
}
ui.captureButton.addEventListener("click", simulateCapture);
ui.retryButton.addEventListener("click", retryPendingNotifications);
ui.resetButton.addEventListener("click", resetLab);
ui.designMode.addEventListener("change", renderMode);
ui.notificationHealthy.addEventListener("change", () => {
addLog(
ui.notificationHealthy.checked
? "Notification service marked available."
: "Notification service marked unavailable.",
ui.notificationHealthy.checked ? "good" : "warn"
);
});
resetLab();
Before you retry the pending notification, do you think recovery changes durable history or only completes the missing side effect?
- Turn on Notification service available.
- Click Retry pending.
You should see exactly one push-style notification for capture-001. The durable history count should stay at one.
The retry panel should return to No pending notifications. That recovery closes the eventual-consistency gap without replaying the history write.
The first conversation has completed its recovery cycle. Now use the other partition to prove that the queue key follows the selected conversation.
- Click Reset lab.
- Set Conversation to conversation-77.
- Turn on Deliver a duplicate event.
- Turn off Notification service available.
Before you trigger the capture, which partition key do you expect to see in both enqueue log entries?
- Click Simulate screenshot.
You should see conversation-77 in both enqueue log entries. You should also see one history item, one ignored duplicate, and one pending retry.
- Turn on Notification service available.
Before the final retry, do you expect the notification count to become one while the persisted count remains one?
- Click Retry pending.
You should see one notification arrive for conversation-77. The retry queue should return to zero while durable history remains unchanged.
Does the retry stay pending?
- Confirm that Notification service available is turned on before selecting Retry pending.
- Confirm that retryPendingNotifications() removes items with state.retryQueue.shift().
- Confirm that the retry button listener appears below the capture button listener.
Still seeing a pending item? Help me debug why my recovered notification retry is not draining the queue.
That's the reliability loop complete. Your simulator now preserves history through an outage, absorbs duplicate delivery, and recovers notification work independently. Next, you'll turn these behaviors into a production architecture you can trace in an interview.
Draw the Production Architecture
Your CodePen simulator now proves the durable path under failure. It keeps conversation history safe while notification delivery recovers separately.
A production design must expose where the mobile platform produces the screenshot signal. It must also show how the API protects ingestion.
The browser prototype cannot represent production scale. Your Excalidraw diagram will make the missing production boundaries visible.
In this step, get ready to:
- Map the main event path across the production boundaries.
- Add production safeguards around the durable path.
- Export a diagram that states the consistency contract.
Map the end-to-end event path
A normal webpage has no standard screenshot-taken event. The viewer mobile client owns this production boundary.
An event-driven architecture separates the producer from the systems that process its event. Start by turning the simulator stages into production components.
- Switch back to the Excalidraw tab from earlier.
- Use the shape tools in the Excalidraw toolbar to add a box labeled Viewer mobile client.
You should now see the mobile boundary where the operating system supplies the screenshot signal.
- Add a box labeled Authenticated screenshot-event API to the right of the viewer client.
- Add a box labeled Durable event stream to the right of the API.
Your diagram now has an ingestion boundary followed by a durable recovery point.
- Add a box labeled Screenshot event processor to the right of the durable stream.
- Add a box labeled Idempotency store beneath the processor.
The processor now has a separate place to check whether an eventId was already handled.
- Add a box labeled Conversation event store to the right of the processor.
- Add a box labeled Notification fan-out below the conversation event store.
You should now see durable history as a distinct destination from notification delivery.
- Add a box labeled Mobile push provider to the right of notification fan-out.
- Add a box labeled Sender devices to the right of the push provider.
The sender side now has its own delivery boundary.
- Use connector arrows to trace the main route from the viewer mobile client to the screenshot event processor.
- Connect the screenshot event processor to the conversation event store.
You should now see a continuous durable route from mobile capture to conversation history.
- Connect the screenshot event processor to the idempotency store with a lookup path.
- Connect the screenshot event processor to notification fan-out with a separate delivery path.
The processor now checks duplicates before the two side effects split.
- Connect notification fan-out to the mobile push provider.
- Connect the mobile push provider to the sender devices.
You should now see the complete production route from the viewer device to durable history plus sender delivery.
- Label the durable event stream with Partition key: conversationId.
How does this map to the simulator?
- The event.partitionKey value maps to the durable stream label conversationId.
- The processedEventIds set maps to the idempotency store.
- The state.history collection maps to the conversation event store.
- The state.retryQueue collection maps to a recovery path outside durable history.
Add production safeguards
Failure isolation keeps notification problems away from durable conversation history. A dead-letter destination catches deliveries that exhaust recovery attempts.
Observability gives operators evidence during an outage. Each metric answers a different operational question.
- Add a control labeled Rate limiting before the authenticated screenshot-event API.
- Add a control labeled Conversation-membership authorization beside the authenticated screenshot-event API.
The ingestion boundary now limits abusive traffic. It also rejects screenshot events from viewers outside the conversation.
- Draw a recovery loop from notification fan-out labeled Exponential retry with jitter.
- Add a destination labeled Dead-letter destination after the exhausted retry path.
The notification branch now shows bounded recovery plus a destination for unresolved deliveries.
- Add a logging branch labeled Redacted logs from the processing path.
- Add an operations panel labeled Observability near the durable stream.
Your operations area now separates safe diagnostic records from the request path.
- Add Queue-lag monitoring to the operations panel.
- Add Duplicate counters to the operations panel.
The panel can now reveal backlog growth plus repeated delivery.
- Add Notification success rate to the operations panel.
- Add End-to-end latency to the operations panel.
You should now see signals for stream health plus user-facing delivery performance.
- Connect the durable event stream to the operations panel.
- Connect notification fan-out to the operations panel.
The monitoring paths now show where each operational signal originates.
Finalize the architecture
Eventual consistency is the contract between history and notification delivery. History can be durable while push remains pending.
- Add the annotation Durable history is the source of truth beside the conversation event store.
- Add the annotation Push notification is best effort and retryable beside notification fan-out.
The diagram now distinguishes durable product state from a retryable delivery side effect.
- Add the annotation Clients may temporarily disagree between the viewer client and sender devices.
- Add the annotation Reconnecting clients read the persisted conversation event beside the conversation event store.
The consistency contract now explains how the clients converge after a delayed notification.
- Switch back to your CodePen Pen from earlier.
- Select Save in the CodePen editor header.
That is your browser proof locked in. The complete durable simulator is saved without code changes.
- Return to the Excalidraw canvas from earlier.
- Select Save to download the editable scene.
Your editable architecture is now protected outside browser storage.
- Select Save as image to open the image export options.
- Choose PNG as the image format.
- Download the image using the available export control.
You now have an editable architecture for future changes plus an image you can share or present.
Before you inspect the export, do you expect one uninterrupted route to cover both durable history and push delivery?
- Open the exported PNG from the save location you chose.
- Trace the durable route from the viewer mobile client to the conversation event store.
- Trace the notification route from notification fan-out to the sender devices.
- Point to the idempotency store as the duplicate check.
- Point to the conversationId partition key on the durable event stream.
- Point to the retry loop plus its dead-letter destination.
- Point to the security controls around the screenshot-event API.
- Point to the four signals in the operations panel.
What should you see?
- The ingestion boundary shows rate limiting plus conversation-membership authorization.
- The durable stream shows conversationId as its partition key.
- The processor checks the idempotency store before persisting one conversation event.
- The notification path includes fan-out, a mobile push provider, retry with jitter, and a dead-letter destination.
- The operations panel tracks queue lag, duplicates, notification success rate, and end-to-end latency.
- The consistency notes identify durable history as the source of truth while push delivery recovers independently.
Strong work. Your exported diagram now explains the same failure behavior that your simulator demonstrates.
Secret mission
Design for a Hot Conversation
Drive a burst through conversation-42 to expose a hot partition. Then split its traffic across deterministic shards. Preserve a sequence number so a later boundary can restore order.
Clean Up Your Resources
Clean Up Your Resources
Both CodePen Free and Excalidraw Free cost $0 for this project. Choose whether to keep the saved work, pause it, or delete it.
Resources you used:
- One public CodePen Pen containing index.html, style.css, and script.js.
- One Excalidraw architecture scene stored in your browser.
- One saved editable Excalidraw scene file.
- One exported architecture image.
Keep everything running
No action is needed while you continue demoing the reliability lab. Your CodePen Pen remains public on the Free plan.
- Keep the saved CodePen Pen available for future demonstrations.
- Keep real messages outside the public Pen.
- Keep the Excalidraw scene stored in this browser for quick edits.
- Retain the editable Excalidraw scene file in its saved location.
- Retain the exported architecture image in its saved location.
Pause - I'll come back to this later
Pausing closes the browser workspace while preserving the simulator and architecture files. You can return without rebuilding either artifact.
- Return to the CodePen tab from earlier.
- Click Save in CodePen.
- Return to the folder in Finder where you saved the architecture exports.
- Confirm the editable Excalidraw scene file is present.
- Confirm the exported architecture image is present.
- Close the CodePen tab.
- Close the Excalidraw tab.
Delete - I don't want to use this again
Deleting the project can feel final. CodePen keeps the Pen recoverable for three days before removing it permanently.
Remove the CodePen Pen
- Return to the saved Pen's page in CodePen.
- Use the Pen's delete option from that page.
- Open the Deleted Pens area in CodePen.
- Confirm the Pen is listed for the three-day recovery period.
Clear the Excalidraw browser scene
- Return to the Excalidraw tab from earlier.
- Select every object on the architecture canvas.
- Delete the selected objects from the canvas.
- Confirm the Excalidraw canvas is blank.
Delete the saved architecture exports
- In Finder, return to the folder where you saved the architecture exports.
- Move the editable Excalidraw scene file to Trash.
- Move the exported architecture image to Trash.
- Empty the Trash.
- Confirm neither export appears in its saved folder.
Nice Work!
Nice Work!
You did it by building a screenshot-alert reliability simulator in CodePen. Your Excalidraw architecture turns those results into an interview-ready system design.
You've learned how to:
- Demonstrate synchronous coupling by forcing notification delivery to fail. The alert disappears without durable history or a retry record.
- Build durable asynchronous messaging with conversation-based partitioning keyed by conversationId. You added idempotent processing for duplicate eventId deliveries. You also added notification retries for failed side effects.
- Produce an interview-ready architecture covering security, scale, observability, and failure handling. You defined eventual consistency with durable conversation history as the source of truth.
- Complete the optional Secret Mission to push your high-scale system design skills further.
Ready to quiz yourself?