<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ timothy ogbemudia - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ timothy ogbemudia - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 24 Oct 2026 21:46:51 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/glamboyosa/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Make Your Coding Agent Check Its Work in the Browser ]]>
                </title>
                <description>
                    <![CDATA[ I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend. You've probably seen the routine. The agent changes some code, runs the formatter, the type chec ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-make-your-coding-agent-check-its-work-in-the-browser/</link>
                <guid isPermaLink="false">6ad731804f84a96f720e9e9d</guid>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Tue, 20 Oct 2026 09:16:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cbbc37b8-4405-4aa3-a691-0d7c7301baf2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend.</p>
<p>You've probably seen the routine. The agent changes some code, runs the formatter, the type checker, and the tests, everything comes back green, and you get some version of "Implemented and verified."</p>
<p>But verified how, exactly? A passing test suite is useful evidence, but it isn't the same as opening the app and using the thing that just changed.</p>
<p>I've had plenty of changes where the code made sense and the tests passed, and the UI still had the kind of problem a person spots in about three seconds. A control overlapping something else. A selected state that exists in the store but isn't highlighted on screen. Reset working for one path and not another. A filter that updates state but leaves the old data on the page. Save looking fine until you refresh. A map layer drawn in the wrong order. A modal that technically opens but doesn't fit on the screen.</p>
<p>The agent had tested its code, but nobody had tested the application. So I started making browser review part of the definition of done for anything user-facing, and a few weeks in, that habit turned into a tool. I'll get to that near the end.</p>
<p>In this article, you'll learn how to give a coding agent a real browser with Playwright MCP, and how to write the instructions that make it test the change it just made instead of confirming that a page loads.</p>
<p>You'll see how to have the agent start your app the way your project already starts it, how to split those instructions between <code>AGENTS.md</code> and a reusable skill, why a separate reviewer agent catches bugs the author misses, and how to make the agent clean up only what it started.</p>
<p>In the last part, I'll walk through katalepsis, the open source tool this turned into, including how it decides whether a check really passed.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-loop-agents-are-missing">The Loop Agents Are Missing</a></p>
</li>
<li><p><a href="#heading-why-a-browser-on-its-own-falls-short">Why a Browser on Its Own Falls Short</a></p>
</li>
<li><p><a href="#heading-how-to-start-the-real-app">How to Start the Real App</a></p>
</li>
<li><p><a href="#heading-where-the-instructions-live">Where the Instructions Live</a></p>
</li>
<li><p><a href="#heading-why-the-reviewer-should-work-independently">Why the Reviewer Should Work Independently</a></p>
</li>
<li><p><a href="#heading-clean-up-after-yourself-and-only-yourself">Clean Up After Yourself and Only Yourself</a></p>
</li>
<li><p><a href="#heading-when-a-skill-stopped-being-enough">When a Skill Stopped Being Enough</a></p>
</li>
<li><p><a href="#heading-how-to-try-katalepsis">How to Try katalepsis</a></p>
</li>
<li><p><a href="#heading-how-katalepsis-works-under-the-hood">How katalepsis Works Under the Hood</a></p>
</li>
<li><p><a href="#heading-what-happened-when-i-used-it">What Happened When I Used It</a></p>
</li>
<li><p><a href="#heading-where-i-landed">Where I Landed</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This isn't a beginner tutorial, so it helps to have:</p>
<ul>
<li><p>Some experience with a coding agent that supports MCP (Model Context Protocol), such as Claude Code, Codex, or Cursor, and an idea of how you give it instructions.</p>
</li>
<li><p>A web app you can run locally with a dev script, written in JavaScript or TypeScript. The examples use a Node project with <code>pnpm</code>, but nothing depends on a specific framework.</p>
</li>
<li><p>Basic Playwright knowledge: what a test, a locator, and an assertion look like. You don't need an existing test suite to follow the first half.</p>
</li>
<li><p>Node.js 20 or newer for Playwright MCP, and Node.js 22.12 or newer plus Git if you want to install katalepsis.</p>
</li>
<li><p>Comfort with the terminal and Git, since the agent will be starting processes and checking <code>git status</code>.</p>
</li>
</ul>
<h2 id="heading-the-loop-agents-are-missing">The Loop Agents Are Missing</h2>
<p>Coding agents got useful quickly in large part because they already have a decent feedback loop for code. They read, edit, and run the formatter and tests, read the failure, and try again. Nobody has to get it right in one heroic attempt.</p>
<p>For frontend work, the clear missing input is the frontend itself. The loop I want goes a bit further: edit the code, run the tests, start the app, use the changed feature, look at what happened, fix whatever's broken, and then use it again.</p>
<p>That last step matters more than you might think. Agents are very comfortable telling you a fix "should now work", and I'd rather they just open the page again and find out.</p>
<p>Playwright MCP is a good way to give them that. I already used Playwright for E2E tests, but the MCP server is useful for a different reason: it hands the agent a browser directly. It can open localhost, read the page, find controls, click and type, check what changed, and take screenshots.</p>
<p>That isn't the same as asking the agent to run <code>pnpm playwright test</code>. The test suite tells you whether the scenarios you already wrote down still pass. Browser review asks what this particular change could have affected and goes to look.</p>
<p>If an agent refactors some shared map-layer utilities, the tests might cover a few helpers. But from the diff, it can also work out that the code runs during initial render, selection, filtering, and reset, and try each of those. That's a lot closer to how I review my own frontend work.</p>
<p>Setup is small. You need Node 20 or newer and an MCP-capable client:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest"]
    }
  }
}
</code></pre>
<p>A quick sanity check is to ask the agent to open the Playwright TodoMVC demo, add a todo called "Browser QA," and confirm it shows up. If it manages that, it has a browser. What it does with one is the harder part.</p>
<h2 id="heading-why-a-browser-on-its-own-falls-short">Why a Browser on Its Own Falls Short</h2>
<p>Giving an agent a browser only gives it the ability to look. It doesn't tell the agent what to look at, how far to go, or what counts as proof, and without that it falls back to the cheapest thing that technically involves a browser. An agent with Playwright can still do hilariously weak QA. I've had reports like this:</p>
<pre><code class="language-text">Opened /route.
Page loaded successfully.
Browser verification complete.
</code></pre>
<p>That's a report about opening a page, not about testing a change. Nothing in it would have caught any of the bugs from the start of this article.</p>
<p>So the browser needed instructions to go with it. What follows are the rules I wrote for how an agent should review its own frontend work: what to test, what to check, and what evidence to bring back. They started as a few lines I kept repeating in prompts, and they later became the browser-review skill you'll see in the next sections.</p>
<p>The first rule I settled on is that the review has to come from the change. If it changed a filter, use the filter. If it changed reset logic, change some state first and then reset it. If it touched persistence, refresh the page. If it changed a modal, open and close the modal, and if it changed responsive layout, resize the viewport.</p>
<p>That all sounds obvious when a person says it, but it isn't obvious at all if your agent instructions just say "verify that the app works".</p>
<p>In practice, this means the agent reads the diff and turns it into a short test plan before it touches the browser. For a change to a filter bar, the plan I'd expect looks something like this:</p>
<pre><code class="language-text">Changed: src/features/projects/FilterBar.tsx, src/features/projects/useFilters.ts

Plan:
1. Open /projects and note how many rows are showing.
2. Filter by Status: Active. Every row should say Active.
3. Add Owner: me on top. The list should narrow again.
4. Clear filters. The original row count should come back.
5. Apply a filter and refresh. The filter lives in the URL, so it should survive.
</code></pre>
<p>Then it carries out that plan through the browser tools Playwright MCP exposes. A trimmed version of the calls for the first few steps looks like this:</p>
<pre><code class="language-text">browser_navigate   { "url": "http://localhost:5173/projects" }
browser_snapshot   {}                      → table with 40 rows
browser_click      { "element": "Status filter", "ref": "e14" }
browser_click      { "element": "Active option", "ref": "e31" }
browser_snapshot   {}                      → table with 12 rows, all "Active"
browser_click      { "element": "Clear filters", "ref": "e15" }
browser_snapshot   {}                      → table with 40 rows
</code></pre>
<p>The <code>ref</code> values come from the accessibility snapshot, which is how the agent finds a control without guessing at CSS selectors.</p>
<p>This paid off on a PR that fixed a cursor getting stuck in its hover state on an interactive map. The PR's new unit tests passed against the old code too, so they didn't prove the fix did anything. What did prove it was the agent opening the app, moving on and off the interactive layers, and watching the cursor change and reset exactly when it should.</p>
<p>The second rule is to check the end result rather than the interaction. "I clicked Reset and nothing threw" doesn't tell you much. What you actually care about is that reset puts things back the way they were. Same with saving: the real claim is that you can change a setting, save, refresh, and it's still there.</p>
<p>Written as a Playwright test, that claim looks like this. The important line is the last one, which runs after the reload:</p>
<pre><code class="language-ts">import { test, expect } from "@playwright/test";

test("weekly digest setting survives a reload", async ({ page }) =&gt; {
  await page.goto("/settings/notifications");
  await page.getByRole("switch", { name: "Weekly digest" }).click();
  await page.getByRole("button", { name: "Save" }).click();

  await page.reload();

  await expect(page.getByRole("switch", { name: "Weekly digest" })).toBeChecked();
});
</code></pre>
<p>The agent doesn't need to write this test to follow the rule. Through MCP it does the same thing by hand: click, save, <code>browser_navigate</code> to the same URL, and read the switch's state from a fresh snapshot.</p>
<p>I also ask for different evidence depending on the question. Whether the URL changed, a button is disabled, or a dialog opened is a question for the DOM and browser state, not a screenshot. Whether that dialog is clipped, two things overlap, or text wraps badly on a phone is a visual question, and that's where screenshots are useful. Sometimes you need both, since a modal can sit happily in the DOM while being unusable on screen.</p>
<p>For a layout change, that means resizing and capturing what a person would actually see:</p>
<pre><code class="language-text">browser_resize            { "width": 375, "height": 812 }
browser_click             { "element": "Export button", "ref": "e22" }
browser_take_screenshot   { "filename": "export-dialog-375.png" }
browser_resize            { "width": 1440, "height": 900 }
browser_take_screenshot   { "filename": "export-dialog-1440.png" }
</code></pre>
<p>While it's in there, the agent may as well look at the console and, when the feature depends on an API call, the network. I only care about errors the change introduced, though. A warning that's been on <code>main</code> for six months shouldn't derail every review, and a client-only transition doesn't need a network audit. If a save that "worked" actually got a <code>500</code>, I want to hear about it.</p>
<p><code>browser_console_messages</code> and <code>browser_network_requests</code> return what the page logged and requested, so a failed save shows up as a line like this even when the UI looked fine:</p>
<pre><code class="language-text">[PUT] http://localhost:5173/api/settings =&gt; [500] Internal Server Error
</code></pre>
<p>Put together, the rules turn the weak report from the start of this section into something I can actually check:</p>
<pre><code class="language-text">Route: /settings/notifications
Did: turned on "Weekly digest", clicked Save, reloaded the page.
Checked: switch still on after reload (aria-checked="true");
  PUT /api/settings returned 200; no new console errors.
Screenshots: settings-375.png, settings-1440.png (label wraps cleanly on mobile)
Tests: pnpm test settings (14 passed)
Found and fixed: Save stayed disabled after the first save. Fixed in
  SettingsForm.tsx, then went through the flow again.
</code></pre>
<h2 id="heading-how-to-start-the-real-app">How to Start the Real App</h2>
<p>The agent can't test anything until the app is running, and this is where it's most tempting for it to take shortcuts.</p>
<p>I didn't want to write "run <code>pnpm dev</code>" into every repository, so the procedure tells the agent to work out how the project starts. It checks the <code>dev</code> script in <code>package.json</code> first, then whatever the README documents, and only falls back to an existing Docker setup if the app can't reasonably run otherwise.</p>
<p>The procedure here is the "Start the application" section of the browser-review skill, which you'll see in full in the next section. In a typical Vite or Next.js project, following it looks something like this:</p>
<pre><code class="language-bash"># 1. How does this project start?
jq .scripts package.json
# {
#   "dev": "vite",
#   "build": "vite build",
#   "test": "vitest run",
#   "e2e": "playwright test"
# }

# 2. Start the dev server in the background, in a temp folder the agent owns,
#    and record the PID so it can stop exactly this process later.
QA_DIR=$(mktemp -d)
pnpm dev --port 5173 &gt; "$QA_DIR/dev.log" 2&gt;&amp;1 &amp;
echo $! &gt; "$QA_DIR/dev.pid"

# 3. Wait until the server answers before opening a browser.
until curl -sf http://localhost:5173 &gt; /dev/null; do sleep 1; done
</code></pre>
<p>If there's no <code>dev</code> script, the README is next, and if the README says the app needs Postgres and Redis from <code>docker compose up</code>, the agent uses that compose file and records the containers it started. It doesn't write a new one.</p>
<p>What I really don't want is the agent changing the app to make it easier to test. No temporary auth bypass, no fake server, no edits to <code>.env</code>, and no throwaway route that exists only because it couldn't work out the real setup. The point is to exercise the software I'm actually shipping.</p>
<p>This is the kind of change I mean, and agents really do reach for it when a login page is in the way:</p>
<pre><code class="language-diff">- if (!session) redirect("/login");
+ if (!session &amp;&amp; process.env.NODE_ENV !== "development") redirect("/login");
</code></pre>
<p>If the agent can't get past authentication with the project's documented test account or seed data, the right move is to stop and report that browser review is blocked and why. A blocked review is honest. A review of an app with its auth switched off isn't a review of my app.</p>
<h2 id="heading-where-the-instructions-live">Where the Instructions Live</h2>
<p>My first instinct was to keep adding to <code>AGENTS.md</code>. It already held the rules every agent in the repo should know: structure, conventions, testing philosophy, keep changes surgical, and no pointless comments.</p>
<p>I was about to add another hundred lines about starting the app, browser setup, what to click, screenshots, console errors and teardown...but at some point that file stops being instructions and turns into a small religious text.</p>
<p>It helps to know how each of these files is actually used. <code>AGENTS.md</code> is a plain Markdown file that coding agents such as Codex read automatically at the start of every session in that repository, and Claude Code does the same with <code>CLAUDE.md</code>. Everything in it lands in the agent's context for every task, whether the task is a CSS fix or a database migration. So it costs context on every run, and it applies everywhere.</p>
<p>A skill works differently. It's a folder with a <code>SKILL.md</code> file that starts with a short <code>name</code> and <code>description</code>. At the start of a session the agent only sees that name and description, a line or two per skill. When a task matches the description, or another instruction points at the skill, the agent reads the rest of the file and follows it. So you can have a long, detailed procedure that costs almost nothing until it's needed.</p>
<p>What I landed on is treating <code>AGENTS.md</code> as the constitution and skills as the playbooks. A constitution is short, applies to everything, and says what must be true without explaining how to do it. A playbook is a step-by-step procedure for one kind of situation, and you only pull it out when you're in that situation.</p>
<p>In this setup, <code>AGENTS.md</code> says that user-visible work isn't done until it's been checked in a browser, and the browser-review skill says how to do the checking:</p>
<pre><code class="language-text">/
├── AGENTS.md
└── .agents/
    └── skills/
        ├── browser-review/
        │   └── SKILL.md
        ├── pr-review/
        │   └── SKILL.md
        └── grill-me/
            └── SKILL.md
</code></pre>
<p>The rule in <code>AGENTS.md</code> stays short and always visible:</p>
<pre><code class="language-md">## Browser Verification

User-visible work is not complete until the affected behavior has been
verified in the running application.

For user-visible changes, follow `.agents/skills/browser-review/SKILL.md`.

A successful build, type check, unit test suite, or E2E test suite does
not replace browser verification.

Do not commit browser-verification artifacts. Stop anything you started
for verification and clean up before hand-off.
</code></pre>
<p>The skill holds the procedure and only gets loaded when it's needed. I use Codex and Claude Code, so there's one global <code>AGENTS.md</code> that Claude's <code>CLAUDE.md</code> imports, and one shared skills folder both of them read. Whichever agent I'm in that day, it follows the same playbook.</p>
<p>The import is a single line, because Claude Code lets <code>CLAUDE.md</code> pull in another file with <code>@</code>:</p>
<pre><code class="language-md">@~/.codex/AGENTS.md
</code></pre>
<p>Here's a shortened version of the one I first wrote:</p>
<pre><code class="language-md">---
name: browser-review
description: Verify user-visible changes in the running application with Playwright MCP.
---

# Browser Review

## Before verification

Run `git status --short` and remember what was already there. Don't touch
pre-existing work.

Read the task and the diff. Work out the affected routes, the changed flow,
the loading, empty, error, selected and disabled states that matter,
whether anything needs to survive a refresh, and what nearby behavior
could regress.

## Start the application

Use the repository's dev script, then its documented local setup, then its
existing Docker setup. Don't invent a new startup config and don't change
app behavior to make QA easier. Record every process and container you start.

## Exercise the change

Don't stop once the page loads. Go through the whole changed flow and check
the final result. Use browser or DOM state for functional claims and
screenshots for visual ones. Report new relevant console errors and failed
requests, not old warnings.

If something is broken, fix the smallest cause, rerun the affected tests
and go through the flow again. Don't assume the fix works.

## Cleanup

Teardown is required even when verification fails. Close browsers, stop
servers and containers, and delete screenshots, traces and other artifacts,
but only the ones you created. Run `git status --short` again before
hand-off. Never run `git add`, `git commit` or `git push`.

## Report

Say which routes you tested, what you did, which states you checked, what
tests you ran and what you found and fixed. If you couldn't finish, say why.
</code></pre>
<p>None of that is clever. The value is that it's the default expectation, so I'm not relying on the agent to decide by itself that it should open a browser.</p>
<h2 id="heading-why-the-reviewer-should-work-independently">Why the Reviewer Should Work Independently</h2>
<p>The clear weakness is that the agent deciding whether the work is done is the same one that wrote it. That's still worth something, since I test my own code too, but it starts with a theory of how its implementation works.</p>
<p>By theory I mean the author's mental model of its own change: which code it touched, what it believes that code affects, and, just as important, what it believes it didn't affect. Its test plan comes straight from that model. So it tests the paths it meant to change, they work, and it never looks at the path it didn't realize was involved. That's the same blind spot people have when they test their own code, and it's exactly where the bugs that survive review tend to live.</p>
<p>So when a second agent reviews a branch, I tell it to work out the test scope for itself. My global rules now require a fresh reviewer with no conversation history: it gets the original request, the diff, and the review skill, and nothing about why the author thinks it works. It decides what the code could affect and tests that, without leaning on the author's screenshots, QA notes, or green test run.</p>
<p>The prompt for that reviewer is short on purpose:</p>
<pre><code class="language-text">You are reviewing a branch you did not write.

Original request:
  Make the E2E app URL configurable through E2E_BASE_URL so the
  tests can run against any port.

Run `git diff main...HEAD` to see the change. Follow the browser-review
skill. Work out for yourself what this change could affect, test that,
and report what you checked with evidence.
</code></pre>
<p>The best catch so far came from a PR that made an end-to-end test setup's app URL configurable. The reviewer noticed the seeded test data still hardcoded image URLs to the old port, and running the app on a different port confirmed it. Three image requests went to the wrong port and failed, and the page quietly rendered fallback cards where the images should have been. Nothing threw.</p>
<p>Simplified, the global setup looked like this after the change:</p>
<pre><code class="language-ts">// e2e/global-setup.ts
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8080";

await seedDatabase({
  media: [
    { id: "m1", url: "http://localhost:8080/test-media/cover-1.jpg" },
    { id: "m2", url: "http://localhost:8080/test-media/cover-2.jpg" },
    { id: "m3", url: "http://localhost:8080/test-media/cover-3.jpg" },
  ],
});
</code></pre>
<p>The author's theory was "the app URL now comes from <code>E2E_BASE_URL</code>", and every test it ran used the default port, where the hardcoded URLs happened to be right. The reviewer had no such theory. It read the whole setup file the diff touched, ran the app on port 8081, and came back with this:</p>
<pre><code class="language-text">Found: seeded media URLs in e2e/global-setup.ts still point at :8080.
Ran the app with E2E_BASE_URL=http://localhost:8081.
Network: 3 requests to http://localhost:8080/test-media/... failed
  (net::ERR_CONNECTION_REFUSED).
Screen: the gallery rendered 3 fallback cards instead of images
  (gallery-8081.png).
Suggested fix: build the seeded URLs from E2E_BASE_URL, or make them
  relative.
</code></pre>
<p>The fix was one line per URL:</p>
<pre><code class="language-ts">{ id: "m1", url: new URL("/test-media/cover-1.jpg", baseURL).href },
</code></pre>
<p>A test that checks the cards exist passes either way, because a fallback card is still a card. You only see it by looking at the page and the network tab, which is the whole argument of this article in one bug.</p>
<h2 id="heading-clean-up-after-yourself-and-only-yourself">Clean Up After Yourself and Only Yourself</h2>
<p>Once agents can start servers and browsers, cleanup is part of the design. Otherwise every session leaves a layer behind: a stray node process, a Chromium window, screenshots, a Playwright trace, a temporary auth file, or a Docker container.</p>
<p>"Clean everything" is the wrong fix, though. <code>pkill node</code> isn't cleanup, it's an agent going on a small rampage, and it'll happily kill the dev server you started in another terminal. <code>git clean -fd</code> will delete an untracked file that has nothing to do with the agent.</p>
<p>The rule is to clean up what you created and leave everything else alone, and the <code>git status</code> before and after is there so browser QA doesn't quietly end up in my next commit.</p>
<p>This is why the startup step records a PID in a temp folder. Cleanup can then be precise:</p>
<pre><code class="language-bash"># Not cleanup
pkill node
git clean -fd

# Cleanup
kill "$(cat "$QA_DIR/dev.pid")"
rm -rf "$QA_DIR"
</code></pre>
<p>And this is roughly what I expect to see at the end of a review:</p>
<pre><code class="language-text">Cleanup
- Stopped the dev server I started (pid 48213, pnpm dev --port 5173).
- Closed the Playwright MCP browser.
- Deleted export-dialog-375.png and export-dialog-1440.png.
- Left running: pid 39102 (pnpm dev on :3000). It was already running
  before I started, so it isn't mine.

git status --short before review:
 M src/features/export/ExportDialog.tsx
?? notes/todo.md

git status --short after cleanup:
 M src/features/export/ExportDialog.tsx
?? notes/todo.md
</code></pre>
<p>The two <code>git status</code> blocks match, so nothing from the review leaked into the working tree, and the untracked notes file that was already there is still there.</p>
<p>I didn't make this rule up in the abstract, either. While building the tool I'm about to describe, the agent testing it ran a cleanup command that closed every Playwright browser on my machine, not just its own. To be fair, it owned up to it in its summary.</p>
<h2 id="heading-when-a-skill-stopped-being-enough">When a Skill Stopped Being Enough</h2>
<p>The skill worked, in the sense that agents started actually opening the app. But what I got back was still prose. The agent would tell me it opened the page, clicked through the flow and everything looked right, and I had to take its word for it.</p>
<p>It's the same problem I started with, just one level up. I didn't want a nicer sentence about the browser. I wanted the evidence: what was checked, what passed, what failed, and the screenshots and traces to back it up, in a form I could drop into a PR for whoever reviews it.</p>
<p>So I wrote a build plan and spent a Saturday building it, pairing with Claude Code through milestones and independent review rounds. It's called <a href="https://www.katalepsis.glamboyosa.xyz">katalepsis</a>, the Stoic word for grasping something so clearly you can't be wrong about it, which felt like a fair bar for a pull request.</p>
<p>You point it at a preview URL and it writes a report: HTML, Markdown you can paste into the PR, and a JSON manifest.</p>
<p>It does two things. <code>katalepsis run</code> runs your existing Playwright tests against the preview, and Playwright's result is final. katalepsis sorts, redacts and bundles it, and never reinterprets an assertion.</p>
<p><code>katalepsis verify</code> takes plain-English checks for behavior nobody has written a test for, and a model drives a real browser with your own Anthropic, OpenAI, or OpenRouter key. There's also an MCP server, so the agent that just made a change can run either one itself and read the report back.</p>
<h2 id="heading-how-to-try-katalepsis">How to Try katalepsis</h2>
<p>katalepsis isn't on npm yet, so the installer clones the repository and builds it. It needs Node 22.12 or newer, Git, and pnpm 10 (or corepack, which ships with Node):</p>
<pre><code class="language-bash">curl -fsSL https://www.katalepsis.glamboyosa.xyz/install.sh | sh
</code></pre>
<p>That puts two launchers, <code>katalepsis</code> and <code>katalepsis-mcp</code>, in <code>~/.local/bin</code>.</p>
<p>To run your existing tests against a preview, your Playwright config has to use the URL katalepsis passes in. It's also worth turning on failure evidence so the report has something to show:</p>
<pre><code class="language-ts">// playwright.config.ts
use: {
  baseURL: process.env.KATALEPSIS_BASE_URL ?? "http://localhost:3000",
  screenshot: "only-on-failure",
  video: "retain-on-failure",
  trace: "retain-on-failure",
},
</code></pre>
<p>The repository includes a small demo app, a signup form with a deliberate bug: submitting the empty form does nothing instead of showing an error. It has three Playwright tests. Here's a real run against it:</p>
<pre><code class="language-text">$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4173
katalepsis: FAILED
  2 passed, 1 failed, 0 blocked, 0 not tested
  Preview commit: not reported, so unconfirmed
  Report: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.html
  Markdown: artifacts/katalepsis/2026-10-19T08-21-25-288Z-3dba5a/report.md
</code></pre>
<p>The Markdown report is meant to be pasted into a PR. The top of it looks like this:</p>
<pre><code class="language-md"># katalepsis report: Failed

**2 passed, 1 failed, 0 blocked** out of 3 checks run.

- Preview: http://127.0.0.1:4173/
- Preview commit: not reported, so unconfirmed
- Runner: katalepsis 0.1.0, Playwright 1.63.0, Node 22.22.0

## Checks

| Status | Check | Project | Location | Duration |
| --- | --- | --- | --- | --- |
| Failed | signup form › empty submission shows a required-field error | chromium | signup.spec.ts:8 | 3.2 s |
| Passed | signup form › valid submission shows a confirmation | chromium | signup.spec.ts:13 | 418 ms |
| Passed | signup form › malformed email shows an email error and does not submit | chromium | signup.spec.ts:20 | 449 ms |

### Failed: signup form › empty submission shows a required-field error

- Failure type: assertion
- Evidence: screenshot, video, log, trace
</code></pre>
<p>Below that, it includes Playwright's own failure message (<code>getByRole('alert')</code> never appeared) and a list of limitations, such as the preview not reporting which commit it was built from.</p>
<p>For behavior without a test, you write checks in plain English. Each one has an intent, which is what to do, and a pass condition, which is what must be true at the end:</p>
<pre><code class="language-json">{
  "checks": [
    {
      "id": "empty-form",
      "name": "Empty form validation",
      "intent": "Submit the signup form without filling it",
      "passCondition": "A required-field error is visible"
    },
    {
      "id": "invalid-email",
      "name": "Invalid email",
      "intent": "Fill the name, enter an invalid email address and submit",
      "passCondition": "An email validation error is visible and the success confirmation is not shown"
    }
  ]
}
</code></pre>
<p>Then you give katalepsis a key and run them. The key is stored in your OS keychain or read from an environment variable, and it's never passed on the command line:</p>
<pre><code class="language-text">$ katalepsis auth set openrouter
$ katalepsis verify --cwd apps/demo-form --url http://127.0.0.1:4173 --checks checks.json \
    --provider openrouter --model anthropic/claude-haiku-5.5
katalepsis: FAILED (openrouter/anthropic/claude-haiku-5.5)
  2 passed, 1 failed, 0 blocked
  Preview commit: not reported, so unconfirmed
  Report: artifacts/katalepsis/2026-10-10T12-45-05-343Z-b51a0a/report.html
</code></pre>
<p>The demo's full <code>checks.json</code> has three checks, and the agent checks find the same empty-form bug the Playwright test did, without anyone having written that test.</p>
<p>Finally, so the coding agent can do all of this itself, you register the MCP server. With Codex, that's:</p>
<pre><code class="language-bash">codex mcp add katalepsis -- katalepsis-mcp --root /path/to/your-app
</code></pre>
<p>The server exposes three tools, <code>verification_run_tests</code>, <code>verification_check_browser</code>, and <code>verification_get_report</code>, and only works inside the folder you give it. In practice the agent's instruction becomes something like "after changing the signup form, run <code>verification_check_browser</code> with a check for the change and paste the Markdown report into your summary."</p>
<h2 id="heading-how-katalepsis-works-under-the-hood">How katalepsis Works Under the Hood</h2>
<p>A few of the decisions came straight out of the problems in this article. Here's how they work in the code.</p>
<p>katalepsis is a TypeScript monorepo with five packages:</p>
<ul>
<li><p><code>core</code> (the result schema, the Playwright runner, and the report renderers)</p>
</li>
<li><p><code>agent</code> (the browser tools and evidence rules for <code>verify</code>)</p>
</li>
<li><p><code>credentials</code> (keys and preferences)</p>
</li>
<li><p><code>mcp</code> (the MCP server)</p>
</li>
<li><p><code>cli</code></p>
</li>
</ul>
<p>It uses Effect for the parts where errors, cancellation, and dependencies matter, such as running Playwright and probing the preview, and Effect Schema for the report format. The model loop in <code>verify</code> uses the Vercel AI SDK, so the same code works with Anthropic, OpenAI, and OpenRouter.</p>
<h3 id="heading-the-report-is-a-schema-not-a-log">The Report is a Schema, Not a Log</h3>
<p>Every run, whether it came from Playwright or from a model, decodes into the same <code>VerificationRun</code> structure before anything is written. A trimmed version of a single check looks like this:</p>
<pre><code class="language-ts">export const CheckStatus = Schema.Literals(["passed", "failed", "blocked"]);

export const VerificationCheck = Schema.Struct({
  id: Schema.String,
  source: Schema.Literals(["playwright-test", "agent"]),
  title: Schema.String,
  status: CheckStatus,
  flaky: Schema.Boolean,
  failure: Schema.NullOr(Schema.Struct({ code: FailureCode, message: Schema.String })),
  attempts: Schema.Array(Attempt),
  evidence: Schema.Array(BundlePath),
});
</code></pre>
<p>Tests that never ran go into a separate <code>notTested</code> list with a reason, which is how you end up with four outcomes: passed, failed, blocked, or not tested. Every evidence file is stored with its SHA-256 hash, and <code>BundlePath</code> rejects absolute paths and <code>..</code>, so a report can't point outside its own folder.</p>
<h3 id="heading-blocked-isnt-failed">Blocked isn't Failed</h3>
<p>If the preview is down, you get one blocked run, not three failed tests that send you hunting for bugs that don't exist. Before starting Playwright, katalepsis requests the preview URL once. This is the core of that probe:</p>
<pre><code class="language-ts">const UNAVAILABLE_STATUSES = new Set([502, 503, 504]);

const response = await fetch(url, {
  redirect: "manual",
  signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
});
if (UNAVAILABLE_STATUSES.has(response.status)) {
  return { reachable: false, reason: `the preview answered with HTTP ${response.status}` };
}
</code></pre>
<p>A network error or a gateway status blocks every selected test, and Playwright never starts. Pointing the demo at a port with nothing on it gives you this:</p>
<pre><code class="language-text">$ katalepsis run --cwd apps/demo-form --url http://127.0.0.1:4999
katalepsis: BLOCKED
  0 passed, 0 failed, 3 blocked, 0 not tested
  preview-unavailable: http://127.0.0.1:4999/ did not respond (connect ECONNREFUSED 127.0.0.1:4999). Playwright was not run.
</code></pre>
<h3 id="heading-playwrights-verdict-is-final">Playwright's Verdict is Final</h3>
<p><code>run</code> starts the project's own Playwright with <code>--reporter=json</code> and reads the result file, rather than reimplementing anything. Playwright already marks a test that passed on retry as <code>flaky</code>, so katalepsis keeps that flag and lists every attempt.</p>
<p>What it does add is classification. A failure whose message names the preview's own origin as unreachable becomes blocked, but a dead third-party API stays failed, because that's a real problem with your app. A browser that couldn't launch is blocked too, which is exactly what I got the first time I ran the demo for this article on a machine with the wrong browser build installed.</p>
<h3 id="heading-the-model-doesnt-get-to-call-something-a-pass">The Model Doesn't Get to Call Something a Pass</h3>
<p>The model in <code>verify</code> can click and type and read the page, but it doesn't get to call something a pass. A check only passes if the model points to a browser assertion that katalepsis ran, that passed, and that shows something that wasn't on the page before the model acted. Otherwise it comes back blocked.</p>
<p>Here's how that works. The model only gets a small set of tools: <code>navigate</code>, <code>snapshot</code>, <code>click</code>, <code>fill</code>, <code>press</code>, <code>assert</code>, and <code>finish</code>. <code>assert</code> doesn't return the model's opinion. katalepsis runs the check in the browser itself, takes a screenshot, and records the result with an ID like <code>a3</code>. When the model calls <code>finish</code>, it has to cite those IDs:</p>
<pre><code class="language-ts">finish: tool({
  description: "Report the verdict once, citing assertion IDs such as a1.",
  inputSchema: schema(
    Schema.Struct({
      verdict: Schema.Literals(["passed", "failed", "blocked"]),
      summary: Schema.String,
      assertionIds: Schema.Array(Schema.String),
    }),
  ),
  execute: (verdict) =&gt; {
    onFinish(verdict);
    return "Recorded.";
  },
}),
</code></pre>
<p>Then a plain function, <code>decideOutcome</code>, compares the claim with the evidence. A trimmed version of the pass rule:</p>
<pre><code class="language-ts">if (!cited.every((assertion) =&gt; assertion.passed)) {
  return "The agent reported a pass, but a cited assertion failed.";
}
const shown = cited.filter((assertion) =&gt; assertion.expectation === "visible");
if (shown.length === 0) {
  return "The agent reported a pass citing only hidden or URL assertions.";
}
if (!observeOnly &amp;&amp; shown.every((assertion) =&gt; assertion.trueAtStart === true)) {
  return "The agent reported a pass, but everything it cited was already on the page before it acted.";
}
</code></pre>
<p>Any of those messages turns the check into blocked with the code <code>no-evidence</code>. Citing an assertion ID that never ran is blocked too. The function also blocks a pass if an uncited assertion failed after the last cited one, because the latest evidence disagrees with the verdict.</p>
<p>That <code>trueAtStart</code> condition exists because one of the models I tested tried to prove a form worked by citing the label of the email field, which had been there all along. T</p>
<p>o catch it, each check opens two copies of the start page in separate browser contexts. The model works in one, and the other stays untouched. When the model asserts that something is visible, katalepsis looks for the same element on the untouched copy, and if it's there too, the assertion gets <code>trueAtStart: true</code> and can't carry a pass on its own.</p>
<h3 id="heading-one-action-at-a-time">One Action at a Time</h3>
<p>The first live runs also caught parallel tool calls stepping on each other, and a name ending up typed into the email field. Models like to send several tool calls at once, and two <code>fill</code> calls racing each other type into whichever field has focus. The fix is a promise queue that every browser action goes through:</p>
<pre><code class="language-ts">private exclusive&lt;A&gt;(action: () =&gt; Promise&lt;A&gt;): Promise&lt;A&gt; {
  const next = this.queue.then(action, action);
  this.queue = next.catch(() =&gt; undefined);
  return next;
}
</code></pre>
<p>Each action waits for the previous one to settle, whether it succeeded or failed, and a failure doesn't poison the queue for the next action.</p>
<h3 id="heading-budgets-and-boundaries">Budgets and Boundaries</h3>
<p>Each agent check runs in its own browser context with tracing on, so the report always has a trace to replay. Navigation is limited to the preview's origin plus anything you allow with <code>--allow-origin</code>. The model has a step limit (<code>--max-steps</code>, 15 by default), a token limit (<code>--max-tokens</code>, 200,000), and a time limit (<code>--check-timeout</code>, 120 seconds), and running out of any of them is reported as blocked with the reason, not as a failure. Query strings are redacted from URLs in the report.</p>
<h3 id="heading-how-it-was-built">How it Was Built</h3>
<p>Before writing code, I wrote a build plan split into milestones. M0 was a review of how two of my earlier projects handled credentials and providers, plus the first architecture decision record and the monorepo tooling. M1 was the Playwright runner and the report, M2 was agent checks with your own key, and M3 was the MCP server. Each milestone ended with a fresh agent with no history reviewing the work against the plan, the same independent-reviewer rule from earlier in this article.</p>
<p>Those reviews shaped the tool more than the plan did. The first one came back with 15 findings, including the rule that only an unreachable preview counts as blocked while other hosts stay failed. The M2 review found that a model could get a pass by citing an assertion that proved nothing, which is where the stricter pass rule and the untouched copy of the page came from. The M3 review found that a client quitting mid-run left Playwright and its browsers running, and that a crafted symlink could let the report tool read files outside the project. All of those are fixed and covered by tests now.</p>
<h2 id="heading-what-happened-when-i-used-it">What Happened When I Used It</h2>
<p>Before telling anyone about it, I ran it against two of my own projects. Conclave's suite came back with 95 passed. A larger app came back with 137 passed and one failure, an assertion on a zoom level that was slightly off.</p>
<p>That was a test I already had and hadn't noticed was failing, so katalepsis didn't find the bug so much as make it impossible to miss. Both projects also hardcoded their <code>baseURL</code>, which meant their tests never touched the URL I passed in. katalepsis now warns about that, because a green run against the wrong server is worse than no run.</p>
<p>After I posted it on LinkedIn, someone in the comments pointed out that the commit you have checked out isn't necessarily the commit the preview was built from. They were right, so the report now records both and marks them confirmed, mismatched, or unknown, and a mismatch blocks a run that would otherwise pass, since a green run against a stale preview tells you nothing about your diff.</p>
<p>The preview reports its commit with an <code>x-commit-sha</code> response header or a <code>&lt;meta name="commit-sha"&gt;</code> tag, which the same probe that checks availability reads. If your host can't do either, you can pass <code>--preview-commit</code> yourself.</p>
<p>I'm dogfooding it in the same instructions this article describes. The browser-review skill now tells my agents to run katalepsis first, and to fall back to plain browser automation for what it can't check yet: field values, persistence, console and network, and layout across viewports.</p>
<p>The first Codex session to pick that up went looking for a "katalepsis skill" because of how I'd worded the line in <code>AGENTS.md</code>, didn't find one, and fell back to its old browser review. Wrong for a silly reason, but the fallback did exactly what it was supposed to, and the fix was saying plainly that katalepsis is a CLI and browser-review is the skill.</p>
<p>A few days later I used it on a real work branch, a change that made GeoJSON the main download button and moved CSV and KML into a dropdown. The agent stopped before running anything, because <code>verify</code> sends page content to the model provider and my rules say it needs my OK before doing that for an app. That's the behavior I want</p>
<p>With seeded synthetic data and an explicit OK, the check passed: one visible primary button, and the other formats showing up once the dropdown opened, with the screenshots and trace in the report. The skill still keeps pages with real data on the fallback path.</p>
<h2 id="heading-where-i-landed">Where I Landed</h2>
<p>Not every task needs any of this. I don't need Playwright to confirm a Markdown typo got fixed, and a backend refactor that can't change what renders doesn't need Chromium. I do want it for components, forms, navigation, dialogs, client state, maps, filtering, responsive layout, persistence, and any user-visible bug fix.</p>
<p>My rule of thumb is that if you're making a claim about how the UI behaves at runtime, the runtime UI should be part of the evidence.</p>
<p>None of this replaces good tests, code review, or me looking at changes that matter. It just adds the feedback loop that was missing. The agent already has the repository and the shell, and it knows what it changed. With a browser it can start answering whether the thing it built actually works, and with a report it can show me instead of telling me. If it can write the button, it can click it and show me what happened before it says it's done.</p>
<p>katalepsis is MIT licensed and on <a href="https://github.com/glamboyosa/katalepsis">GitHub</a>. If an agent check ever gives you a result you don't believe, I'd like to hear about it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make a Static HTML Page Editable in the Browser with Vanilla JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ When you maintain a document for someone else, such as a résumé, a one-page portfolio, or a printable menu, the bottleneck is rarely the layout. It's the edit loop. Every small change ("move this bull ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-make-a-static-html-page-editable-in-the-browser-with-vanilla-javascript/</link>
                <guid isPermaLink="false">6a61266b62f25d8178b980e0</guid>
                
                    <category>
                        <![CDATA[ HTML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CSS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DOM ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 20:22:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/08400ed2-ef6a-404f-a135-b6cefe9d6919.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you maintain a document for someone else, such as a résumé, a one-page portfolio, or a printable menu, the bottleneck is rarely the layout. It's the edit loop.</p>
<p>Every small change ("move this bullet up", "delete that line", "this link is dead") goes through you, the person with the code editor, even though the person requesting the change knows exactly what they want.</p>
<p>I ran into this maintaining a family member's résumé as a single static HTML file. The design was done. The content was theirs. But every revision, whether it was reordering a role, adding a certification, fixing a link, or nudging a print page break, meant another round of "send me the change, I'll edit the file." After the tenth round, the fix became obvious: make the page edit itself.</p>
<p>In this article, you'll build an in-browser editing layer for a static HTML page using <code>contenteditable</code>, about a hundred lines of vanilla JavaScript, and no build step. The person editing can change any text, reorder or delete any block, add new content, edit links, control print pagination, and print to PDF. A refresh restores the original file, untouched.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-what-is-contenteditable">What Iscontenteditable?</a></p>
</li>
<li><p><a href="#heading-why-not-a-react-app">Why Not a React App?</a></p>
</li>
<li><p><a href="#heading-how-to-prepare-the-markup-for-reordering">How to Prepare the Markup for Reordering</a></p>
</li>
<li><p><a href="#heading-how-to-attach-controls-to-every-block">How to Attach Controls to Every Block</a></p>
</li>
<li><p><a href="#heading-how-to-show-controls-only-on-the-innermost-block">How to Show Controls Only on the Innermost Block</a></p>
</li>
<li><p><a href="#heading-how-to-move-and-delete-blocks">How to Move and Delete Blocks</a></p>
</li>
<li><p><a href="#heading-how-to-edit-links-inside-contenteditable">How to Edit Links Insidecontenteditable</a></p>
</li>
<li><p><a href="#heading-how-to-let-users-control-print-pagination">How to Let Users Control Print Pagination</a></p>
</li>
<li><p><a href="#heading-how-to-add-new-content-from-templates">How to Add New Content from Templates</a></p>
</li>
<li><p><a href="#heading-why-nothing-persists">Why Nothing Persists</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><p>Working knowledge of HTML and CSS, including CSS Grid and <code>@media print</code></p>
</li>
<li><p>Basic understanding of JavaScript DOM APIs (<code>querySelector</code>, event listeners, creating elements)</p>
</li>
<li><p>No frameworks, libraries, or build tools. That's the point.</p>
</li>
</ul>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<ul>
<li><p>What <code>contenteditable</code> gives you for free, and where it stops</p>
</li>
<li><p>How to attach move/delete controls to repeatable blocks with one reusable function</p>
</li>
<li><p>How to show controls only on the innermost hovered block using <code>:has()</code></p>
</li>
<li><p>How to reorder DOM nodes without a framework, and the markup prep that makes it safe</p>
</li>
<li><p>How to edit link URLs inside an editable region</p>
</li>
<li><p>How to let users place print page breaks themselves</p>
</li>
<li><p>How to add new content from templates, with placeholder text pre-selected</p>
</li>
<li><p>Why "nothing persists" can be a feature, not a limitation</p>
</li>
</ul>
<h2 id="heading-what-is-contenteditable">What Is <code>contenteditable</code>?</h2>
<p><code>contenteditable</code> is an HTML attribute that turns any element into an editable region. The browser handles the hard parts: caret placement, text selection, typing, deletion, clipboard, and undo history.</p>
<pre><code class="language-html">&lt;div class="page" contenteditable="true" spellcheck="false"&gt;
  &lt;!-- the entire document --&gt;
&lt;/div&gt;
</code></pre>
<p>That one attribute gets you further than you might expect. Clicking any paragraph places a caret. Cmd+Z undoes typing. Pressing Enter inside a <code>&lt;ul&gt;</code> creates a new <code>&lt;li&gt;</code>. The browser understands list semantics natively, so "add a bullet by pressing Enter" works with zero code.</p>
<p>But <code>contenteditable</code> alone isn't an editor. It has no concept of <em>blocks</em>. It won't move a job entry above another one, delete a card cleanly, or change an <code>href</code>. Clicking a link inside an editable region just places the caret in its text. Everything structural is on you. The rest of this article is about filling that gap.</p>
<h2 id="heading-why-not-a-react-app">Why Not a React App?</h2>
<p>The obvious alternative is to rebuild the page as a "real" app: components, state, a form per section, and an export button. I decided against it, and the tradeoff is important.</p>
<p>The file lives in a <code>public/</code> folder and is served as a static asset. It works from a URL, from disk, or from an email attachment. It has no dependencies to install, no build to run, and no way to rot when a toolchain updates.</p>
<p>The editing needs are small and bounded: change text, move blocks, delete blocks, add blocks, and print. That's DOM manipulation, the thing the DOM API is already good at.</p>
<p>A framework earns its complexity when state outlives the DOM: persistence, collaboration, validation, and syncing. This page has none of those requirements. When your state <em>is</em> the DOM and the lifetime is one session, a framework is an extra layer that buys you nothing.</p>
<h2 id="heading-how-to-prepare-the-markup-for-reordering">How to Prepare the Markup for Reordering</h2>
<p>Before writing any JavaScript, look at your markup for anything positional, meaning elements that only make sense <em>between</em> other elements. In my case, jobs were separated by <code>&lt;hr&gt;</code> dividers:</p>
<pre><code class="language-html">&lt;div class="job"&gt;...&lt;/div&gt;
&lt;hr class="divider"&gt;
&lt;div class="job"&gt;...&lt;/div&gt;
</code></pre>
<p>The moment blocks can move or be deleted, separators like this become landmines. Delete a job and its divider survives as an orphaned line. Move a job and the divider stays behind.</p>
<p>The fix is to delete the <code>&lt;hr&gt;</code> elements entirely and derive the separator from adjacency:</p>
<pre><code class="language-css">.section .job + .job {
  border-top: 0.5px solid var(--rule);
  padding-top: 18px;
}
</code></pre>
<p>The sibling combinator draws a rule above every job that follows another job. Reorder them, delete them, add new ones, and the separators are always exactly where they should be, because they're computed from structure rather than stored in it. This is the same principle as deriving state instead of duplicating it, applied to CSS.</p>
<h2 id="heading-how-to-attach-controls-to-every-block">How to Attach Controls to Every Block</h2>
<p>Each movable block gets a small control cluster with move up, move down, and delete actions, injected by one reusable function:</p>
<pre><code class="language-js">const SELECTORS = ['.section', '.job', '.bullets li', '.cert-card', '.skills-row', '.edu-row'];
const BREAKABLE = new Set(['.section', '.job']);

function makeBlock(el, sel) {
  el.classList.add('blk');
  el.dataset.sel = sel;
  const ctl = document.createElement('span');
  ctl.className = 'ctl';
  ctl.setAttribute('contenteditable', 'false');
  ctl.innerHTML =
    '&lt;button data-act="up" data-tip="Move this up"&gt;↑&lt;/button&gt;' +
    '&lt;button data-act="down" data-tip="Move this down"&gt;↓&lt;/button&gt;' +
    (BREAKABLE.has(sel) ? '&lt;button data-act="brk" data-tip="Page break: start a new printed page here"&gt;⇟&lt;/button&gt;' : '') +
    '&lt;button data-act="del" data-tip="Remove this. Refresh to bring it back"&gt;×&lt;/button&gt;';
  el.appendChild(ctl);
}

SELECTORS.forEach(sel =&gt; {
  page.querySelectorAll(sel).forEach(el =&gt; makeBlock(el, sel));
});
</code></pre>
<p>A few design decisions are worth calling out.</p>
<p>First, <code>contenteditable="false"</code> on the control cluster. Editable regions inherit. Everything inside the page is editable unless you opt out. Without this, the user could place a caret inside your buttons and delete them like text.</p>
<p>Second, <code>el.dataset.sel</code> records <em>which selector matched</em>. This matters later: when a block moves, it should only swap with siblings of its own kind. A bullet moves among bullets, a job among jobs. Storing the selector on the element makes that check trivial.</p>
<p>Third, the controls live <em>inside</em> the block they control. That gives you positioning for free, with <code>position: absolute</code> against the block's own <code>position: relative</code>, and means a block carries its controls with it wherever it moves.</p>
<h2 id="heading-how-to-show-controls-only-on-the-innermost-block">How to Show Controls Only on the Innermost Block</h2>
<p>Blocks nest: a bullet sits inside a job, which sits inside a section. Hovering a bullet technically hovers all three, and naïve CSS shows three control clusters at once. The result is visual noise exactly where the user is trying to focus.</p>
<p>Modern CSS solves this in one line:</p>
<pre><code class="language-css">.blk:hover:not(:has(.blk:hover)) &gt; .ctl { display: inline-flex; }
</code></pre>
<p>Read it inside out: show a block's controls when it's hovered, <em>unless</em> some descendant block is also hovered, in which case that deeper block wins. Hover a bullet, you get bullet controls. Hover the job's title (outside any bullet), you get job controls. One rule, no JavaScript.</p>
<p><code>:has()</code> is supported in every current browser, but a fallback costs one more rule:</p>
<pre><code class="language-css">@supports not selector(:has(*)) {
  .blk:hover &gt; .ctl { display: inline-flex; }
}
</code></pre>
<p>Older browsers get the noisier all-ancestors behavior instead of no controls at all. Degrade loudly, not silently.</p>
<h2 id="heading-how-to-move-and-delete-blocks">How to Move and Delete Blocks</h2>
<p>With controls attached, the actual reordering is short. One delegated listener handles every button on the page:</p>
<pre><code class="language-js">function siblings(el) {
  return [...el.parentElement.children].filter(c =&gt;
    c.classList.contains('blk') &amp;&amp; c.dataset.sel === el.dataset.sel);
}

page.addEventListener('click', e =&gt; {
  const btn = e.target.closest('.ctl button');
  if (!btn) return;
  e.preventDefault();
  const el = btn.closest('.blk');
  const sibs = siblings(el);
  const i = sibs.indexOf(el);
  const act = btn.dataset.act;
  if (act === 'up' &amp;&amp; i &gt; 0) sibs[i - 1].before(el);
  else if (act === 'down' &amp;&amp; i &lt; sibs.length - 1) sibs[i + 1].after(el);
  else if (act === 'del') el.remove();
  else if (act === 'brk') el.classList.toggle('page-break');
});
</code></pre>
<p><code>siblings()</code> is where <code>dataset.sel</code> pays off: it filters the parent's children down to blocks <em>of the same kind</em>, so a job can never swap into the middle of a bullet list. <code>before()</code> and <code>after()</code> move the live node with no cloning or re-rendering, and the block's own controls travel with it.</p>
<p>There's one subtle bug to prevent. Clicking a button inside an editable region moves the text caret first, which can scroll the page or collapse a selection. Suppress it at <code>mousedown</code>, before the browser acts:</p>
<pre><code class="language-js">page.addEventListener('mousedown', e =&gt; {
  if (e.target.closest('.ctl, .add-btn')) e.preventDefault();
});
</code></pre>
<p>Forgetting this is the kind of thing you only notice as a vague feeling that clicking buttons "jumps." It's worth ruling out before it ships.</p>
<h2 id="heading-how-to-edit-links-inside-contenteditable">How to Edit Links Inside <code>contenteditable</code></h2>
<p>Inside an editable region, single-clicking a link places the caret instead of navigating. That's correct for text editing but leaves no way to change the URL itself. The <code>href</code> isn't text, it's an attribute.</p>
<p>Double-click is unclaimed real estate, so hang URL editing off it:</p>
<pre><code class="language-js">page.addEventListener('dblclick', e =&gt; {
  const a = e.target.closest('a');
  if (!a) return;
  e.preventDefault();
  const url = prompt('Link URL (leave empty to remove the link):', a.getAttribute('href'));
  if (url === null) return;
  if (!url.trim()) a.replaceWith(document.createTextNode(a.textContent));
  else a.setAttribute('href', url.trim());
});
</code></pre>
<p>Yes, <code>prompt()</code>. It's unfashionable, but consider what a custom modal would cost: markup, styles, focus management, and an escape handler, all for a dialog that asks one question. <code>prompt()</code> is native, keyboard-accessible, and can't break.</p>
<p>The empty-string branch is a nice touch: it unwraps the link entirely, replacing it with its own text, so "remove this link" doesn't require knowing any HTML.</p>
<p>Since none of this is discoverable, tell the user. A <code>title</code> attribute on every link ("Double-click to change this link") surfaces the affordance exactly where it's needed.</p>
<h2 id="heading-how-to-let-users-control-print-pagination">How to Let Users Control Print Pagination</h2>
<p>If the document's destination is a printed PDF, page breaks are content decisions, like "start my Skills section on page three," and the user should own them. CSS makes the break itself easy:</p>
<pre><code class="language-css">@media print {
  .page-break { break-before: page; page-break-before: always; }
  .job { break-inside: avoid; page-break-inside: avoid; }
}
</code></pre>
<p>The interesting part is the interface. The <code>⇟</code> button you saw in <code>makeBlock</code> just toggles the <code>page-break</code> class on a job or section. On screen, the class renders as a dashed accent line above the block, a visible seam showing where the printed page will end:</p>
<pre><code class="language-css">.page .page-break {
  border-top: 1.5px dashed var(--accent) !important;
  padding-top: 14px !important;
}

@media print {
  .page .page-break { border-top: none !important; padding-top: 0 !important; }
}
</code></pre>
<p>The dashed line exists only on screen. In print it vanishes and the actual break takes its place. The user toggles, glances at the seam, and prints. Nobody edits CSS to re-paginate a document, and just as importantly, nobody asks me to.</p>
<p>The same <code>@media print</code> block hides every piece of editing chrome (<code>.toolbar, .ctl, .add-btn { display: none !important; }</code>), so the printed output is indistinguishable from the original static page.</p>
<h2 id="heading-how-to-add-new-content-from-templates">How to Add New Content from Templates</h2>
<p>Editing and deleting only go so far. Eventually someone needs a new bullet, a new role, or a new certification. Each repeatable container gets a dashed "+ Add" button that builds a blank block from a template:</p>
<pre><code class="language-js">skillsSection.appendChild(newAddBtn('+ Add skill row', 'Adds a blank row. Type over the placeholder.', btn =&gt; {
  const row = document.createElement('div');
  row.className = 'skills-row';
  row.innerHTML =
    '&lt;span class="skill-label"&gt;Label&lt;/span&gt;' +
    '&lt;span class="skill-items"&gt;Skill one, skill two, skill three&lt;/span&gt;';
  skillsSection.insertBefore(row, btn);
  makeBlock(row, '.skills-row');
  selectText(row.querySelector('.skill-label'));
}));
</code></pre>
<p>Two details here do most of the work.</p>
<p>New blocks go through the same <code>makeBlock</code> as everything parsed at load. There is exactly one code path for "this is a block now", so added content is immediately movable and deletable. For roles it gets its own nested "+ Add bullet" button.</p>
<p>If you find yourself writing a second registration path for dynamic content, stop. You're about to fork behavior that must stay identical.</p>
<p>And <code>selectText</code> pre-selects the placeholder:</p>
<pre><code class="language-js">function selectText(node) {
  const range = document.createRange();
  range.selectNodeContents(node);
  const sel = getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}
</code></pre>
<p>Click "+ Add skill row" and the word <code>Label</code> is already highlighted, so typing replaces it. No clicking into the field, no manually deleting placeholder text, and no placeholders accidentally left in the printed document.</p>
<p>One caveat: select the <em>text node</em>, not the block. The block contains your <code>contenteditable="false"</code> control cluster, and a selection spanning it will delete your buttons along with the placeholder on the first keystroke.</p>
<h2 id="heading-why-nothing-persists">Why Nothing Persists</h2>
<p>Every edit lives in the DOM and dies on refresh. That sounds like the missing feature, but it's the design.</p>
<p>The workflow this page serves is: open, adjust, print to PDF, close. The PDF is the artifact. The page is a template you stamp from. Ephemerality gives you a free, bulletproof undo-everything (refresh), zero risk of a half-finished edit becoming the new baseline, and a canonical version that always matches source control.</p>
<p>The toolbar says it plainly: <em>"Nothing is saved. Refresh resets everything."</em> Stated upfront, it reads as a guarantee rather than a gotcha.</p>
<p>Persistence would also be the complexity cliff. The moment edits survive refresh you inherit serialization, versioning, merge conflicts with the source file, and "which copy is real?" Those are the exact problems this design exists to avoid.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a static HTML page that edits itself: <code>contenteditable</code> for text, one <code>makeBlock</code> function for structure, <code>:has()</code> for focused hover controls, a class toggle for print pagination, and templates with pre-selected placeholders for new content. Around a hundred lines of JavaScript, with no dependencies and no build.</p>
<p>Just as important is knowing when this approach stops being right. If edits must persist, if multiple people edit concurrently, or if the content needs validation and workflow, you've outgrown the DOM-as-state model. In those cases, reach for a real application and a database.</p>
<p>But for the wide middle ground of documents that one person adjusts and prints, such as résumés, invoices, certificates, and programmes, the browser already ships the editor. You just have to turn it on.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Reliable SSE Client in TypeScript ]]>
                </title>
                <description>
                    <![CDATA[ When you build a feature that streams data, like an AI chat response or a live notification feed, the network is rarely as cooperative as fetch makes it look. Connections drop, proxies buffer response ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-reliable-sse-client-in-typescript/</link>
                <guid isPermaLink="false">6a3db0651016f6a6b4bd2a89</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streaming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SSE ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Thu, 25 Jun 2026 22:49:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3c13d795-15e8-452a-b490-89528d58efd2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you build a feature that streams data, like an AI chat response or a live notification feed, the network is rarely as cooperative as <code>fetch</code> makes it look.</p>
<p>Connections drop, proxies buffer responses, and mobile networks switch from WiFi to cellular mid-stream. If your streaming code doesn't plan for this, the user sees a response that just stops, with no error and no recovery.</p>
<p>In this article, you'll use an open source TypeScript library called <a href="https://github.com/glamboyosa/ore">Ore</a> as a practical example of how to build a streaming client that handles real-world network conditions: automatic retries, the official Server-Sent Events (SSE) parsing spec, and clean integration with React and React Server Components.</p>
<p>By the end, you'll understand how async generators, the Fetch API, and the SSE spec fit together to build something far more reliable than a basic <code>fetch</code> and <code>response.body.getReader()</code> loop.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-what-is-server-sent-events">What Is Server-Sent Events?</a></p>
</li>
<li><p><a href="#heading-why-build-a-custom-streaming-client">Why Build a Custom Streaming Client?</a></p>
</li>
<li><p><a href="#heading-how-to-stream-raw-chunks-with-an-async-generator">How to Stream Raw Chunks with an Async Generator</a></p>
</li>
<li><p><a href="#heading-how-to-parse-server-sent-events-by-hand">How to Parse Server-Sent Events by Hand</a></p>
</li>
<li><p><a href="#heading-how-to-implement-reconnection-with-last-event-id">How to Implement Reconnection with Last-Event-ID</a></p>
</li>
<li><p><a href="#heading-how-to-handle-retries-with-backoff">How to Handle Retries with Backoff</a></p>
</li>
<li><p><a href="#heading-how-to-use-this-with-react">How to Use This with React</a></p>
</li>
<li><p><a href="#heading-how-to-use-this-with-react-server-components">How to Use This with React Server Components</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><p>A working understanding of TypeScript</p>
</li>
<li><p>Familiarity with <code>fetch</code>, <code>ReadableStream</code>, and <code>async</code>/<code>await</code></p>
</li>
<li><p>Basic knowledge of React (for the React-specific sections)</p>
</li>
</ul>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<ul>
<li><p>How to stream raw text or bytes from a <code>fetch</code> response using async generators</p>
</li>
<li><p>How to parse the Server-Sent Events spec by hand, field by field</p>
</li>
<li><p>How to implement automatic reconnection with <code>Last-Event-ID</code> so you don't lose events</p>
</li>
<li><p>How to handle retries with exponential backoff</p>
</li>
<li><p>How to integrate a streaming client with React state and React Server Components</p>
</li>
</ul>
<h2 id="heading-what-is-server-sent-events">What Is Server-Sent Events?</h2>
<p>Server-Sent Events (SSE) is a web standard for one-way streaming from server to client over a single HTTP connection. Unlike WebSockets, it's plain HTTP, which means it works through existing infrastructure like load balancers and proxies without special configuration.</p>
<p>An SSE response looks like this on the wire:</p>
<pre><code class="language-plaintext">event: update
id: 42
data: {"status": "processing"}

event: update
id: 43
data: {"status": "complete"}
</code></pre>
<p>Each event is separated by a blank line. The <code>data</code> field carries the payload, <code>event</code> names the event type, and <code>id</code> lets the client track its position in the stream for reconnection.</p>
<p>The browser has a built-in <code>EventSource</code> API for this, but it has real limitations: no custom headers, no POST requests, and inconsistent reconnection behavior across browsers. For anything beyond the simplest case, you often need to parse the stream yourself.</p>
<h2 id="heading-why-build-a-custom-streaming-client">Why Build a Custom Streaming Client?</h2>
<p>Many streaming use cases, like AI chat responses, don't use the SSE spec at all. They're just raw chunks of text arriving over time. Other cases, like live notifications, genuinely benefit from the structure SSE provides: named events, IDs for resumption, and a server-controlled retry interval.</p>
<p>Ore handles both with two separate functions:</p>
<ul>
<li><p><code>stream()</code> for raw text or byte streaming, with no assumptions about format</p>
</li>
<li><p><code>streamSSE()</code> for spec-compliant SSE parsing</p>
</li>
</ul>
<p>Both are async generators, so consuming either looks the same from the call site:</p>
<pre><code class="language-typescript">for await (const chunk of stream("https://api.example.com/chat")) {
  console.log(chunk);
}
</code></pre>
<h2 id="heading-how-to-stream-raw-chunks-with-an-async-generator">How to Stream Raw Chunks with an Async Generator</h2>
<p>The simplest case is streaming raw text. This is useful for AI responses or log tails where there's no event structure, just a sequence of bytes arriving over time.</p>
<p>Here's the core of <code>stream()</code>:</p>
<pre><code class="language-typescript">export async function* stream(
  url: string,
  options?: StreamOptions
): AsyncGenerator&lt;string | Uint8Array, void, unknown&gt; {
  const { headers, retries = 3, signal, decode = true } = options || {};

  let retryCount = 0;

  while (retryCount &lt;= retries) {
    try {
      const response = await fetch(url, { method: "GET", headers, signal });

      if (!response.body) {
        throw new Error("Response body is null");
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          yield decode ? decoder.decode(value, { stream: true }) : value;
        }
      } finally {
        reader.releaseLock();
      }

      return;
    } catch (error: any) {
      if (signal?.aborted) throw error;
      retryCount++;
      if (retryCount &gt; retries) {
        throw new Error(`Max retries exceeded. Last error: ${error.message}`);
      }
      await new Promise((r) =&gt; setTimeout(r, 1000 * retryCount));
    }
  }
}
</code></pre>
<p>A few design decisions are worth calling out.</p>
<p>The function is an async generator (<code>async function*</code>), so the caller can use <code>for await...of</code> instead of managing a reader and a loop manually. That's the difference between exposing a raw <code>ReadableStream</code> and exposing something pleasant to consume.</p>
<p>The <code>finally</code> block always releases the reader lock, even if the loop exits early through a <code>break</code> or an exception. Forgetting this is a common source of stream leaks.</p>
<p>The retry loop only catches errors from the <code>fetch</code> call and the read loop. If the <code>AbortSignal</code> was the cause of the failure, it rethrows immediately rather than retrying, since retrying a deliberate cancellation makes no sense.</p>
<h2 id="heading-how-to-parse-server-sent-events-by-hand">How to Parse Server-Sent Events by Hand</h2>
<p>The SSE spec is a simple text format, but parsing it correctly means handling several edge cases: events split across multiple data lines, comment lines starting with a colon, fields with no value, and incomplete lines at the end of a chunk.</p>
<p>Here's the core state machine inside <code>streamSSE()</code>:</p>
<pre><code class="language-typescript">let buffer = "";
let currentEvent: Partial&lt;SSEEvent&gt; = { data: "", event: null, id: null };
let hasData = false;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split(/\r\n|\r|\n/);
  buffer = lines.pop() || ""; // keep the last incomplete line for the next chunk

  for (const line of lines) {
    if (line === "") {
      if (hasData) {
        const event: SSEEvent = {
          id: currentEvent.id ?? lastEventId,
          event: currentEvent.event ?? null,
          data: currentEvent.data!.endsWith("\n")
            ? currentEvent.data!.slice(0, -1)
            : currentEvent.data!,
          retry: currentEvent.retry,
        };
        if (event.id) lastEventId = event.id;
        yield event;
        currentEvent = { data: "", event: null, id: null };
        hasData = false;
      }
      continue;
    }

    if (line.startsWith(":")) continue; // comment line, ignore

    const colonIndex = line.indexOf(":");
    const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
    let valueStr = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
    if (valueStr.startsWith(" ")) valueStr = valueStr.slice(1);

    switch (field) {
      case "data":
        currentEvent.data += valueStr + "\n";
        hasData = true;
        break;
      case "event":
        currentEvent.event = valueStr;
        break;
      case "id":
        if (valueStr.indexOf("\0") === -1) currentEvent.id = valueStr;
        break;
      case "retry":
        const retry = parseInt(valueStr, 10);
        if (!isNaN(retry)) retryInterval = retry;
        break;
    }
  }
}
</code></pre>
<p>A network chunk doesn't respect line boundaries. A single <code>read()</code> call might end mid-line, so the last, possibly incomplete line is held back in <code>buffer</code> and prepended to the next chunk rather than processed early. This is the part of SSE parsing that's easy to get wrong if you reach for a naïve <code>response.text()</code> and a string split.</p>
<p>The blank line is what ends an event. SSE events don't have a fixed-length header. The spec says a blank line marks the boundary, so the parser only yields an event once it has seen one.</p>
<p>The <code>id</code> field is rejected outright if it contains a null byte, per the spec. That's a small detail that almost no hand-rolled implementation gets right on the first try.</p>
<h2 id="heading-how-to-implement-reconnection-with-last-event-id">How to Implement Reconnection with Last-Event-ID</h2>
<p>This is the part of SSE that gives it a real advantage over a plain <code>fetch</code> stream: built-in support for resuming after a disconnect without losing your place.</p>
<pre><code class="language-typescript">let lastEventId: string | null = null;

while (retryCount &lt;= retries) {
  const headers = { ...customHeaders };
  if (lastEventId) {
    (headers as any)["Last-Event-ID"] = lastEventId;
  }

  const response = await fetch(url, { method: "GET", headers, signal });
  // ... read and parse events, updating lastEventId as they arrive
}
</code></pre>
<p>Every time an event with an <code>id</code> field arrives, <code>lastEventId</code> is updated. If the connection drops and the client reconnects, it sends <code>Last-Event-ID</code> in the request headers. A well-behaved server can use that header to resume the stream from the right point instead of replaying everything or skipping ahead.</p>
<p>This only works if the server actually honors the header, so it's a contract between client and server, not something the client can guarantee alone. But having the client track and send it correctly is the necessary half of that contract.</p>
<h2 id="heading-how-to-handle-retries-with-backoff">How to Handle Retries with Backoff</h2>
<p>Both <code>stream()</code> and <code>streamSSE()</code> retry on failure, but they do it slightly differently based on what failed.</p>
<p><code>stream()</code> uses a simple linear backoff tied to the retry count:</p>
<pre><code class="language-typescript">await new Promise((resolve) =&gt; setTimeout(resolve, 1000 * retryCount));
</code></pre>
<p><code>streamSSE()</code> respects the server-specified <code>retry</code> field from the SSE spec when one is provided, falling back to a default otherwise:</p>
<pre><code class="language-typescript">let retryInterval = 1000;
// ... updated from the "retry" field if the server sends one
await new Promise((r) =&gt; setTimeout(r, retryInterval));
</code></pre>
<p>Letting the server influence the retry interval matters in practice. A server under load can tell clients to back off longer, which is exactly the kind of cooperative behavior the SSE spec was designed to support.</p>
<p>In both functions, an aborted <code>AbortSignal</code> always short-circuits the retry loop. Treating a deliberate cancellation as a retryable failure is a common bug, and the fix is just checking <code>signal?.aborted</code> before deciding to retry.</p>
<h2 id="heading-how-to-use-this-with-react">How to Use This with React</h2>
<p>Because both functions are async generators, integrating with React state is a matter of looping and calling <code>setState</code> per chunk:</p>
<pre><code class="language-typescript">function ChatComponent() {
  const [messages, setMessages] = useState("");

  useEffect(() =&gt; {
    const controller = new AbortController();

    (async () =&gt; {
      try {
        for await (const chunk of stream("/api/chat", { signal: controller.signal })) {
          setMessages((prev) =&gt; prev + chunk);
        }
      } catch (err: any) {
        if (err.name !== "AbortError") console.error(err);
      }
    })();

    return () =&gt; controller.abort();
  }, []);

  return &lt;div&gt;{messages}&lt;/div&gt;;
}
</code></pre>
<p>The cleanup function calling <code>controller.abort()</code> is doing real work here. Without it, navigating away from the component while a stream is still active leaves the fetch running in the background, updating state on an unmounted component.</p>
<h2 id="heading-how-to-use-this-with-react-server-components">How to Use This with React Server Components</h2>
<p>Because the generator yields values one at a time, you can also drive a recursive Suspense boundary directly from the async iterator, streaming HTML to the client as each chunk arrives:</p>
<pre><code class="language-typescript">async function StreamViewer({ iterator }: { iterator: AsyncIterator&lt;string&gt; }) {
  const { value, done } = await iterator.next();
  if (done) return null;

  return (
    &lt;span&gt;
      {value}
      &lt;Suspense&gt;
        &lt;StreamViewer iterator={iterator} /&gt;
      &lt;/Suspense&gt;
    &lt;/span&gt;
  );
}

export default function Page() {
  const dataStream = stream("https://api.example.com/stream");
  const iterator = dataStream[Symbol.asyncIterator]();

  return (
    &lt;Suspense fallback="Loading..."&gt;
      &lt;StreamViewer iterator={iterator} /&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<p>Each recursive call awaits the next chunk and renders a nested <code>Suspense</code> boundary for the rest. React streams each piece of HTML to the client as it resolves, rather than waiting for the entire response.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A reliable streaming client needs to handle more than the success path. Connections drop, chunks arrive split across line boundaries, and cancellation needs to be distinguished from failure.</p>
<p>Ore's approach to this is built from a small set of ideas:</p>
<ul>
<li><p>Expose streams as async generators so consumers can use <code>for await...of</code></p>
</li>
<li><p>Parse SSE by hand, field by field, respecting the spec's blank-line event boundaries and buffering incomplete lines across chunks</p>
</li>
<li><p>Track <code>Last-Event-ID</code> so reconnection can resume rather than restart</p>
</li>
<li><p>Treat retries and cancellation as separate concerns</p>
</li>
<li><p>Stay framework-agnostic at the core, with thin integration points for React and React Server Components</p>
</li>
</ul>
<p>That combination is what separates a streaming client that works in a demo from one that holds up against real network conditions. You can explore the full source code at <a href="https://github.com/glamboyosa/ore">github.com/glamboyosa/ore</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a PostgreSQL-Backed Job Queue in Go ]]>
                </title>
                <description>
                    <![CDATA[ When you build a web application, not every task should happen inside a user's request. Some work is slow. Some work can fail. Some work should happen later. Sending emails, resizing images, processin ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-postgresql-backed-job-queue-in-go/</link>
                <guid isPermaLink="false">6a28a0135ea1e6904efb11dc</guid>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Queues ]]>
                    </category>
                
                    <category>
                        <![CDATA[ golang ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Tue, 09 Jun 2026 23:21:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f16f87ae-8900-40e9-ba3b-64bf50cc1fe1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you build a web application, not every task should happen inside a user's request.</p>
<p>Some work is slow. Some work can fail. Some work should happen later. Sending emails, resizing images, processing webhooks, generating reports, and retrying third-party APIs are all good examples.</p>
<p>These tasks are usually handled by a background job system.</p>
<p>In this article, you'll use an open source Go project called <a href="https://github.com/glamboyosa/swig">Swig</a> as a practical example of how a PostgreSQL-backed job queue works in practice.</p>
<p>By the end, you'll understand how to build a background job queue with Go and PostgreSQL, and why PostgreSQL is more capable than most developers realize.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-what-is-a-job-queue">What Is a Job Queue?</a></p>
</li>
<li><p><a href="#heading-why-use-postgresql-for-a-queue">Why Use PostgreSQL for a Queue?</a></p>
</li>
<li><p><a href="#heading-swigs-architecture">Swig's Architecture</a></p>
</li>
<li><p><a href="#heading-how-to-represent-jobs-in-postgresql">How to Represent Jobs in PostgreSQL</a></p>
</li>
<li><p><a href="#heading-how-to-define-a-worker-in-go">How to Define a Worker in Go</a></p>
</li>
<li><p><a href="#heading-how-to-register-workers-without-sharing-state">How to Register Workers Without Sharing State</a></p>
</li>
<li><p><a href="#heading-how-to-add-a-job">How to Add a Job</a></p>
</li>
<li><p><a href="#heading-how-to-handle-multiple-workers-safely">How to Handle Multiple Workers Safely</a></p>
</li>
<li><p><a href="#heading-how-to-use-goroutines-for-concurrent-workers">How to Use Goroutines for Concurrent Workers</a></p>
</li>
<li><p><a href="#heading-how-to-wake-workers-with-listennotify">How to Wake Workers with LISTEN/NOTIFY</a></p>
</li>
<li><p><a href="#heading-how-to-elect-a-leader-with-advisory-locks">How to Elect a Leader with Advisory Locks</a></p>
</li>
<li><p><a href="#heading-how-to-handle-failed-jobs">How to Handle Failed Jobs</a></p>
</li>
<li><p><a href="#heading-how-to-abstract-the-database-driver">How to Abstract the Database Driver</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><p>Basic familiarity with Go (structs, interfaces, goroutines)</p>
</li>
<li><p>A working understanding of PostgreSQL and SQL</p>
</li>
<li><p>Go installed (1.21 or later)</p>
</li>
<li><p>A PostgreSQL instance available locally or remotely</p>
</li>
</ul>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<ul>
<li><p>How to represent and store jobs in PostgreSQL</p>
</li>
<li><p>How to claim jobs safely across concurrent workers using <code>FOR UPDATE SKIP LOCKED</code></p>
</li>
<li><p>How to wake workers efficiently using <code>LISTEN/NOTIFY</code></p>
</li>
<li><p>How to elect a leader across instances using advisory locks</p>
</li>
<li><p>How Go interfaces, goroutines, contexts, and transactions fit together in a real system</p>
</li>
</ul>
<h2 id="heading-what-is-a-job-queue">What Is a Job Queue?</h2>
<p>A job queue is a system that stores work to be done later.</p>
<p>Your application adds a job to the queue. A worker takes a job from the queue and runs it.</p>
<p>For example, when a user signs up, your application might create the user immediately and then add a job like this:</p>
<pre><code class="language-json">{
  "kind": "send_welcome_email",
  "payload": {
    "to": "user@example.com",
    "subject": "Welcome!"
  }
}
</code></pre>
<p>A background worker later picks up that job and sends the email. This keeps the user request fast. The signup route doesn't need to wait for the email provider before returning a response.</p>
<p>A job queue usually needs to answer a few important questions:</p>
<ul>
<li><p>Where are jobs stored?</p>
</li>
<li><p>How do workers find jobs?</p>
</li>
<li><p>How do you stop two workers from processing the same job?</p>
</li>
<li><p>How do you retry failed jobs?</p>
</li>
<li><p>How do you shut workers down safely?</p>
</li>
<li><p>How do you keep job creation consistent with application data?</p>
</li>
</ul>
<p>Swig answers those questions with Go and PostgreSQL.</p>
<h2 id="heading-why-use-postgresql-for-a-queue">Why Use PostgreSQL for a Queue?</h2>
<p>Many job queues use Redis, RabbitMQ, SQS, or Kafka. Those are all useful tools. But many applications already depend on PostgreSQL. If your app already has Postgres, you may not want to operate another service just to run background jobs.</p>
<p>PostgreSQL gives you several features that are surprisingly useful for queues:</p>
<ul>
<li><p>Tables for durable job storage</p>
</li>
<li><p>Transactions for atomic writes</p>
</li>
<li><p>Row locks for safe concurrent processing</p>
</li>
<li><p><code>SKIP LOCKED</code> for letting workers claim different jobs</p>
</li>
<li><p><code>LISTEN/NOTIFY</code> for waking workers when new jobs arrive</p>
</li>
<li><p>Advisory locks for leader election</p>
</li>
<li><p>JSONB for flexible job payloads</p>
</li>
</ul>
<p>The tradeoff is important. A PostgreSQL-backed queue isn't trying to replace Kafka for event streaming or RabbitMQ for complex routing. It makes common application background jobs simple, reliable, and easy to operate without adding infrastructure.</p>
<h2 id="heading-swigs-architecture">Swig's Architecture</h2>
<p>At a high level, Swig has five parts:</p>
<ol>
<li><p>A <code>swig_jobs</code> table in PostgreSQL</p>
</li>
<li><p>Go workers that process jobs</p>
</li>
<li><p>A worker registry that maps job names to worker types</p>
</li>
<li><p>A driver layer that supports both <code>pgx</code> and <code>database/sql</code></p>
</li>
<li><p>A leader loop for shared maintenance work</p>
</li>
</ol>
<p>The basic flow looks like this:</p>
<ol>
<li><p>Your app calls <code>AddJob</code></p>
</li>
<li><p>Swig serializes the job payload to JSON</p>
</li>
<li><p>Swig inserts a row into <code>swig_jobs</code></p>
</li>
<li><p>PostgreSQL sends a notification that a job was created</p>
</li>
<li><p>A Go worker wakes up and tries to claim one pending job</p>
</li>
<li><p>PostgreSQL row locks ensure only one worker claims that row</p>
</li>
<li><p>The worker runs the job</p>
</li>
<li><p>Swig marks the job as completed or failed</p>
</li>
</ol>
<p>The hard parts are concurrency, failure, connection lifecycle, and shutdown. That's where Go and PostgreSQL work together.</p>
<h2 id="heading-how-to-represent-jobs-in-postgresql">How to Represent Jobs in PostgreSQL</h2>
<p>A simplified version of Swig's job table looks like this:</p>
<pre><code class="language-sql">CREATE TABLE swig_jobs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  kind TEXT NOT NULL,
  queue TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  priority INTEGER NOT NULL DEFAULT 0,
  attempts INTEGER NOT NULL DEFAULT 0,
  max_attempts INTEGER NOT NULL DEFAULT 3,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  instance_id UUID,
  worker_id UUID,
  locked_at TIMESTAMPTZ,
  last_error TEXT,
  last_error_at TIMESTAMPTZ
);
</code></pre>
<p>Each row is one job. The important columns are:</p>
<ul>
<li><p><code>kind</code>: the type of job, such as <code>send_email</code></p>
</li>
<li><p><code>payload</code>: the JSON data needed to run the job</p>
</li>
<li><p><code>status</code>: whether the job is pending, processing, completed, or failed</p>
</li>
<li><p><code>attempts</code>: how many times the job has been tried</p>
</li>
<li><p><code>scheduled_for</code>: when the job is allowed to run</p>
</li>
<li><p><code>locked_at</code>: when the job was claimed</p>
</li>
</ul>
<p>The table is the source of truth. PostgreSQL notifications can wake workers, but notifications aren't the durable queue. The rows in <code>swig_jobs</code> are.</p>
<h2 id="heading-how-to-define-a-worker-in-go">How to Define a Worker in Go</h2>
<p>In Swig, a worker is a Go type that knows how to process one kind of job.</p>
<p>Here's a simple email worker:</p>
<pre><code class="language-go">type EmailWorker struct {
    To      string `json:"to"`
    Subject string `json:"subject"`
    Body    string `json:"body"`
}

func (w *EmailWorker) JobName() string {
    return "send_email"
}

func (w *EmailWorker) Process(ctx context.Context) error {
    fmt.Printf("Sending email to %s with subject %s\n", w.To, w.Subject)
    return nil
}
</code></pre>
<p>There are two important methods:</p>
<ul>
<li><p><code>JobName</code> tells Swig what kind of job this worker handles</p>
</li>
<li><p><code>Process</code> contains the actual work</p>
</li>
</ul>
<p>The struct fields are also the job arguments. When you enqueue an <code>EmailWorker</code>, Swig serializes the struct into JSON and stores it in PostgreSQL. Later, a worker claims the row, unmarshals the JSON back into a fresh <code>EmailWorker</code>, and calls <code>Process</code>.</p>
<h3 id="heading-go-interfaces">Go Interfaces</h3>
<p>Go interfaces describe behavior. Swig doesn't need to know the exact concrete type of every worker. It only needs to know that a worker can provide a job name and process a job:</p>
<pre><code class="language-go">type Worker interface {
    JobName() string
    Process(context.Context) error
}
</code></pre>
<p>If a type has those methods, it satisfies the interface with no explicit declaration required. This is one of the reasons interfaces are so useful in Go. They let you design around behavior instead of inheritance.</p>
<h2 id="heading-how-to-register-workers-without-sharing-state">How to Register Workers Without Sharing State</h2>
<p>Swig has a worker registry that maps a job name to a worker type:</p>
<pre><code class="language-go">registry := workers.NewWorkerRegistry()
registry.RegisterWorker(&amp;EmailWorker{})
</code></pre>
<p>Later, when a job row says <code>kind = 'send_email'</code>, Swig looks up the registered worker and runs it.</p>
<p>There's a subtle concurrency issue here. If the registry stored the exact <code>&amp;EmailWorker{}</code> pointer and reused it for every job, multiple goroutines could unmarshal payloads into the same Go value at the same time.</p>
<p>Swig avoids this with a factory approach internally. Registration captures the worker type, and each claimed job gets a fresh worker instance before JSON is unmarshaled. The API stays simple, but internally Swig creates a new <code>EmailWorker</code> for each job. This is a useful Go pattern: keep the public API simple while making the internal lifecycle safer.</p>
<h2 id="heading-how-to-add-a-job">How to Add a Job</h2>
<p>Here's what adding a job looks like from the user side:</p>
<pre><code class="language-go">err := swigClient.AddJob(ctx, &amp;EmailWorker{
    To:      "user@example.com",
    Subject: "Welcome!",
    Body:    "Thanks for signing up.",
})
</code></pre>
<p>Inside Swig, the process is roughly:</p>
<pre><code class="language-go">argsJSON, err := json.Marshal(workerWithArgs)
if err != nil {
    return err
}

_, err = db.ExecContext(ctx, `
    INSERT INTO swig_jobs (kind, queue, payload, priority, scheduled_for, status)
    VALUES (\(1, \)2, \(3, \)4, $5, 'pending')
`, jobName, queue, argsJSON, priority, runAt)
</code></pre>
<h3 id="heading-how-to-enqueue-jobs-inside-transactions">How to Enqueue Jobs Inside Transactions</h3>
<p>One of the best reasons to use PostgreSQL for jobs is transactional enqueueing.</p>
<p>Imagine a user signs up. You want to insert the user and queue a welcome email. If those happen separately, you can get inconsistent states. With a transaction, both succeed or both fail:</p>
<pre><code class="language-go">tx, err := pool.Begin(ctx)
if err != nil {
    return err
}
defer tx.Rollback(ctx)

_, err = tx.Exec(ctx, `INSERT INTO users (email) VALUES ($1)`, email)
if err != nil {
    return err
}

err = swigClient.AddJobWithTx(ctx, tx, &amp;EmailWorker{
    To:      email,
    Subject: "Welcome!",
    Body:    "Thanks for joining.",
})
if err != nil {
    return err
}

return tx.Commit(ctx)
</code></pre>
<p>If the transaction rolls back, the user isn't created and the job isn't queued. This is much harder to guarantee when your database and queue are separate systems.</p>
<h2 id="heading-how-to-handle-multiple-workers-safely">How to Handle Multiple Workers Safely</h2>
<p>A queue gets interesting when many workers run at the same time. Imagine three workers all asking PostgreSQL for the next pending job. You don't want all three to process the same job.</p>
<p>A naïve approach has a race condition. Two workers can select the same job before either one updates it.</p>
<h3 id="heading-postgresql-for-update-skip-locked">PostgreSQL FOR UPDATE SKIP LOCKED</h3>
<p>PostgreSQL can lock rows selected inside a transaction. <code>FOR UPDATE</code> means "lock this row because I plan to update it." <code>SKIP LOCKED</code> means "if another worker already locked a row, skip it and find another one."</p>
<p>This is perfect for a queue:</p>
<ul>
<li><p>Worker A locks job 1</p>
</li>
<li><p>Worker B skips job 1 and locks job 2</p>
</li>
<li><p>Worker C skips jobs 1 and 2 and locks job 3</p>
</li>
</ul>
<p>No central coordinator is needed. Swig uses an atomic update pattern:</p>
<pre><code class="language-sql">UPDATE swig_jobs
SET status = 'processing',
    instance_id = $1,
    worker_id = $2,
    locked_at = NOW(),
    attempts = attempts + 1
WHERE id = (
  SELECT id
  FROM swig_jobs
  WHERE status = 'pending'
    AND scheduled_for &lt;= NOW()
  ORDER BY priority DESC, created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING id, kind, payload;
</code></pre>
<p>This query finds a pending job, skips already-locked jobs, marks it as processing, records which worker claimed it, and returns the job data. All of this happens atomically. Workers never do a separate <code>SELECT</code> and hope the later <code>UPDATE</code> is still safe.</p>
<h2 id="heading-how-to-use-goroutines-for-concurrent-workers">How to Use Goroutines for Concurrent Workers</h2>
<p>Swig starts worker loops as goroutines:</p>
<pre><code class="language-go">for i := 0; i &lt; maxWorkers; i++ {
    go s.startWorker(ctx, queueType)
}
</code></pre>
<p>Each worker runs independently. PostgreSQL coordinates which job each worker gets. Go handles concurrency with goroutines, while PostgreSQL handles safe job claiming with locks.</p>
<h3 id="heading-how-to-handle-graceful-shutdown">How to Handle Graceful Shutdown</h3>
<p>When a service shuts down, it should wait for workers to finish cleanly. Go's <code>sync.WaitGroup</code> helps:</p>
<pre><code class="language-go">var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    processJobs()
}()

wg.Wait()
</code></pre>
<p>Swig also uses <code>sync.Once</code> to make shutdown idempotent. Calling <code>Stop</code> more than once shouldn't panic because of a double channel close. Shutdown paths are often where production systems behave differently from happy-path demos.</p>
<h2 id="heading-how-to-wake-workers-with-listennotify">How to Wake Workers with LISTEN/NOTIFY</h2>
<p>If workers constantly poll the database for jobs, they waste resources when the queue is empty. PostgreSQL has <code>LISTEN/NOTIFY</code> to solve this.</p>
<p>A connection can listen on a channel:</p>
<pre><code class="language-sql">LISTEN swig_jobs;
</code></pre>
<p>Another session can send a notification:</p>
<pre><code class="language-sql">NOTIFY swig_jobs, '{"id":"job-id"}';
</code></pre>
<p>Swig creates a trigger so PostgreSQL sends a notification after a job is inserted. Workers sleep when there's no work and wake when a new job arrives.</p>
<p>There's an important PostgreSQL detail here: <code>LISTEN</code> is session-scoped. A worker must wait for notifications on the same database session that executed <code>LISTEN</code>. Swig handles this by creating a dedicated listener for each worker that owns one database session throughout its lifecycle.</p>
<p>This is a common backend engineering lesson: abstractions like connection pools are useful, but some database features depend on the lifecycle of a specific connection.</p>
<h2 id="heading-how-to-elect-a-leader-with-advisory-locks">How to Elect a Leader with Advisory Locks</h2>
<p>Some queue maintenance tasks should only run on one instance at a time, including retrying failed jobs, recovering stale jobs, and cleaning old history.</p>
<p>Swig uses PostgreSQL advisory locks for this:</p>
<pre><code class="language-sql">SELECT pg_try_advisory_lock($1);
</code></pre>
<p>If the result is true, that Swig instance becomes the leader. Advisory locks are also session-scoped, so Swig uses a dedicated advisory-lock connection for leadership. If that session ends, PostgreSQL releases the lock and another instance can take over. Simple failover without ZooKeeper or etcd.</p>
<h2 id="heading-how-to-handle-failed-jobs">How to Handle Failed Jobs</h2>
<p>When a worker returns an error, Swig records the error and either retries the job or marks it as failed:</p>
<pre><code class="language-sql">UPDATE swig_jobs
SET status = CASE
    WHEN attempts &gt;= max_attempts THEN 'failed'
    ELSE 'pending'
  END,
  last_error = $2,
  last_error_at = NOW()
WHERE id = $1;
</code></pre>
<h3 id="heading-a-note-on-delivery-semantics">A Note on Delivery Semantics</h3>
<p>It's tempting to say a job queue processes jobs exactly once. In distributed systems, that's a dangerous claim.</p>
<p>Consider this scenario:</p>
<ol>
<li><p>A worker sends an email</p>
</li>
<li><p>The worker crashes before marking the job completed</p>
</li>
<li><p>The job is retried</p>
</li>
<li><p>The email might be sent again</p>
</li>
</ol>
<p>The accurate description is that Swig provides atomic claiming and at-least-once processing. Because jobs can be retried, workers should be idempotent. Running the same operation more than once should produce the same result as running it once.</p>
<h2 id="heading-how-to-abstract-the-database-driver">How to Abstract the Database Driver</h2>
<p>Swig supports both <code>pgx</code> and <code>database/sql</code> through a driver interface:</p>
<pre><code class="language-go">type Driver interface {
    Exec(ctx context.Context, sql string, args ...interface{}) error
    Query(ctx context.Context, sql string, args ...interface{}) (Rows, error)
    QueryRow(ctx context.Context, sql string, args ...interface{}) Row
    WithTx(ctx context.Context, fn func(tx Transaction) error) error
    NewListener(ctx context.Context, channel string) (Listener, error)
    TryAdvisoryLock(ctx context.Context, lockID int64) (AdvisoryLock, bool, error)
}
</code></pre>
<p>The core queue code only depends on behavior, not a specific library. This is a common Go design: define the behavior your core package needs, write small adapters for concrete dependencies, and keep the core logic independent.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A PostgreSQL-backed queue isn't the right answer for every system. If you need massive event streaming, Kafka may be a better fit. If you need complex routing, RabbitMQ may be better.</p>
<p>But for many Go applications, PostgreSQL is already there. Swig shows how far you can get with a small Go API and a few PostgreSQL features:</p>
<ul>
<li><p>Store jobs in a table</p>
</li>
<li><p>Claim jobs atomically with <code>FOR UPDATE SKIP LOCKED</code></p>
</li>
<li><p>Wake workers with dedicated <code>LISTEN/NOTIFY</code> sessions</p>
</li>
<li><p>Coordinate leadership with advisory locks</p>
</li>
<li><p>Keep app data and jobs consistent with transactions</p>
</li>
<li><p>Manage worker lifecycles with goroutines and contexts</p>
</li>
</ul>
<p>That combination makes a solid foundation for background processing and a great project for learning how Go and PostgreSQL work together in production systems. You can explore the full source code at <a href="https://github.com/glamboyosa/swig">github.com/glamboyosa/swig</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
