<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: devkitlab</title>
    <description>The latest articles on DEV Community by devkitlab (@devkitlab).</description>
    <link>https://dev.to/devkitlab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3971669%2F7ba0d3ba-db91-4978-b38b-44adb65a66b0.jpg</url>
      <title>DEV Community: devkitlab</title>
      <link>https://dev.to/devkitlab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devkitlab"/>
    <language>en</language>
    <item>
      <title>How to Fix JSON That Won't Parse (and Clean Up the Mess That's Left)</title>
      <dc:creator>devkitlab</dc:creator>
      <pubDate>Tue, 01 Sep 2026 02:01:46 +0000</pubDate>
      <link>https://dev.to/devkitlab/how-to-fix-json-that-wont-parse-and-clean-up-the-mess-thats-left-41jm</link>
      <guid>https://dev.to/devkitlab/how-to-fix-json-that-wont-parse-and-clean-up-the-mess-thats-left-41jm</guid>
      <description>&lt;p&gt;A teammate drops a line into Slack: "why does this blow up?" It's a chunk they copied out of a Python service's logs, and they want it as clean JSON so they can read it. You paste it into a formatter, hit format, and it goes red. Invalid JSON.&lt;/p&gt;

&lt;p&gt;Here's the thing that trips people up: most of the time the tool is right and the input is wrong. Not wrong as in broken beyond repair — wrong as in &lt;em&gt;it was never strict JSON to begin with&lt;/em&gt;. It's a Python dict that got printed, or a JavaScript object someone logged, or a payload with another payload stuffed inside it as a string. All of those look like JSON at a glance and none of them parse. This post walks through the mess in the order you actually hit it: read the error, figure out why it isn't JSON, deal with the sneaky nested case, and then — once it finally parses — make it readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blob we'll fix
&lt;/h2&gt;

&lt;p&gt;This is our teammate's paste, near enough to something real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ada&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"role":"admin","seen":null}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Grace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;None&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Squint and it's obviously &lt;em&gt;data&lt;/em&gt;. But it fails on the very first token, and it has at least four separate reasons to fail buried in five lines. We'll clear them one at a time and use it as the running example the whole way down.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, read the error — it's more helpful than it looks
&lt;/h2&gt;

&lt;p&gt;Before fixing anything, read what the parser actually said. &lt;code&gt;JSON.parse&lt;/code&gt; doesn't fail vaguely; it fails at a specific character, and the message tells you which one. The catch is that the message looks different depending on where you ran it, and that throws people off.&lt;/p&gt;

&lt;p&gt;Take a smaller broken example — a trailing comma — so the position is easy to count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An older V8 engine (Node before 21, older Chrome) reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SyntaxError: Unexpected token } in JSON at position 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Position 15 is a &lt;strong&gt;character offset from the start of the string&lt;/strong&gt;, counting from zero and including every space and quote. Count to 15 and you land on the &lt;code&gt;}&lt;/code&gt; — the parser expected another key after the comma and got the closing brace instead. That number is the whole point: it's a location, not a vague complaint, and it's usually all you need to find the stray character.&lt;/p&gt;

&lt;p&gt;Newer V8 (Node 21+, recent Chrome) rewrote the message and, annoyingly, dropped the plain offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SyntaxError: Unexpected token '}', "{"name": "Ada",}" is not valid JSON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you get a snippet of surrounding text instead of a number. Firefox, meanwhile, has always done the friendliest thing of the three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SyntaxError: JSON.parse: expected property name or '}' at line 1 column 16 of the JSON data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line and column, plus what it &lt;em&gt;expected&lt;/em&gt;. Three engines, three dialects, same underlying fact: something at one exact spot isn't allowed there. When a formatter marks the error location for you and lets you jump straight to that line and column, this is the step it saves you — you stop counting characters by hand and go look at the spot. Paste our big blob into the &lt;a href="https://www.devkitlab.com/en/tools/json-format/" rel="noopener noreferrer"&gt;JSON Formatter&lt;/a&gt; and it stops on the first offender and points at it; click the error and it drops the cursor right there.&lt;/p&gt;

&lt;p&gt;So the first offender in our blob is the &lt;code&gt;u&lt;/code&gt; in &lt;code&gt;users&lt;/code&gt; at the top. Which brings us to &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it isn't JSON: five things that look right and aren't
&lt;/h2&gt;

&lt;p&gt;Strict JSON is a narrow, boring format on purpose — that's what makes it portable. Most "invalid JSON" is really valid &lt;em&gt;something else&lt;/em&gt; that borrowed the curly braces. These are the five that show up constantly, and our blob has four of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keys need double quotes.&lt;/strong&gt; &lt;code&gt;{users: [...]}&lt;/code&gt; is a perfectly good JavaScript object literal, and it is not JSON. JSON requires &lt;code&gt;"users"&lt;/code&gt;. This is the single most common reason a copied JS object or a &lt;code&gt;console.log&lt;/code&gt; of one won't parse — the language lets you skip the quotes, the interchange format doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strings need double quotes, not single.&lt;/strong&gt; &lt;code&gt;'name'&lt;/code&gt; and &lt;code&gt;'admin'&lt;/code&gt; are out. JSON only knows &lt;code&gt;"&lt;/code&gt;. Python's &lt;code&gt;print()&lt;/code&gt;, Ruby's inspect output, and plenty of log formatters emit single quotes, so anything copied from those trips here immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python's booleans and null aren't JSON's.&lt;/strong&gt; Our blob has &lt;code&gt;True&lt;/code&gt;, &lt;code&gt;False&lt;/code&gt;, and &lt;code&gt;None&lt;/code&gt; — those are Python literals. JSON spells them &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt;, lowercase. Same trap with &lt;code&gt;NaN&lt;/code&gt; and &lt;code&gt;Infinity&lt;/code&gt;, which some libraries emit and strict JSON flat-out forbids; there's no valid way to write "not a number" in JSON, which is worth remembering before you rely on it round-tripping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No trailing commas.&lt;/strong&gt; The comma after the last user — &lt;code&gt;None},&lt;/code&gt; then &lt;code&gt;]&lt;/code&gt; — is illegal in JSON even though almost every programming language now allows it. This one bites hardest because editors and linters have trained us to leave trailing commas everywhere; JSON is the one place that still rejects them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No comments.&lt;/strong&gt; Not in our blob, but the same family: &lt;code&gt;//&lt;/code&gt; and &lt;code&gt;/* */&lt;/code&gt; are not JSON. If you're editing a &lt;code&gt;tsconfig.json&lt;/code&gt; or a VS Code settings file with comments in it, that's JSONC, a superset — a strict parser will choke on the comments.&lt;/p&gt;

&lt;p&gt;You can fix these by hand, and for five lines that's fine. For anything bigger it's tedious and error-prone, which is why a good formatter has a repair pass: it will try strict JSON first, then progressively looser interpretations — single quotes, Python literals, trailing commas — and tell you what it had to assume. Repair is a convenience, not a guarantee; it's for reading a log line quickly, not for blessing untrusted input. But on our blob it does the obvious thing and hands back parseable JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sneaky one: JSON inside JSON
&lt;/h2&gt;

&lt;p&gt;Fix the four problems above and the blob parses — but look at Ada's &lt;code&gt;meta&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;seen&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:null}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That value isn't an object. It's a &lt;em&gt;string&lt;/em&gt; that happens to contain JSON, escaped and stuffed inside the outer document. This is everywhere once you start noticing it: a database column typed as text that holds serialized JSON, a webhook that nests its real payload as a string, a JWT whose decoded body is JSON you then have to decode &lt;em&gt;again&lt;/em&gt;, a log field that stringified an object before writing it. A plain formatter will show you that value as one long escaped string and leave you to sort it out.&lt;/p&gt;

&lt;p&gt;There are two moves here. If the &lt;em&gt;whole thing&lt;/em&gt; you pasted is a JSON string — the entire blob wrapped in quotes with &lt;code&gt;\"&lt;/code&gt; everywhere, which is what you get from copying a serialized value — then you &lt;strong&gt;unescape&lt;/strong&gt; it once to turn it back into a structured document. If it's a document that has stringified JSON &lt;em&gt;in some of its fields&lt;/em&gt;, like our &lt;code&gt;meta&lt;/code&gt;, you want to &lt;strong&gt;expand the nested strings&lt;/strong&gt; in place, so &lt;code&gt;"{\"role\":\"admin\"}"&lt;/code&gt; becomes a real nested object you can read and fold. The &lt;a href="https://www.devkitlab.com/en/tools/json-format/" rel="noopener noreferrer"&gt;JSON Formatter&lt;/a&gt; has both, and expanding nested strings is the one people are most relieved to find — it saves the copy-this-value-into-another-tab-paste-unescape-copy-back dance that double-encoded data usually demands.&lt;/p&gt;

&lt;p&gt;After expanding, Ada's record finally reads like data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fassets.devkitlab.com%2Fblog%2Ffix-json-that-wont-parse%2Fexpand-nested.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fassets.devkitlab.com%2Fblog%2Ffix-json-that-wont-parse%2Fexpand-nested.webp" title="Repair fixes the syntax, expand-nested turns the stringified meta field back into an object, and the status bar shows the JSON path." alt="The JSON Formatter after repair and expanding nested strings: the meta field, previously an escaped string, is now a real nested object, with the JSON path for the cursor shown in the status bar"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Now that it parses: minify, pretty-print, or sort keys
&lt;/h2&gt;

&lt;p&gt;Valid JSON is the starting line, not the finish. What you do next depends on why you needed it readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pretty-print&lt;/strong&gt; is the default — indent it, put things on their own lines, make it scannable. Two-space indentation is the common house style and what most formatters produce. This is what you want when you're reading a response or debugging a payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minify&lt;/strong&gt; is the opposite: strip every space and newline down to one line. That's the form you want when the JSON is going &lt;em&gt;into&lt;/em&gt; something — a request body, an environment variable, a single log line, a field in another JSON document. Nobody reads minified JSON; it's for the wire, not for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort keys&lt;/strong&gt; is the one people underuse. JSON objects have no defined key order, so two services can emit the same record with the fields in a different sequence — and the moment you try to compare two payloads, that unstable order buries the real change under noise. Recursively sorting keys before you save a fixture, or before a diff, makes the output stable, so the next comparison shows only what genuinely changed. (One caveat: sorting rewrites the order, so if the original order mattered — a hand-tuned config where fields are grouped deliberately — copy the original first.) This connects directly to the noise problem in &lt;a href="https://www.devkitlab.com/en/blog/compare-two-json-files/" rel="noopener noreferrer"&gt;comparing two JSON files&lt;/a&gt;: normalize both sides first, and the diff tells the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to format it: terminal, editor, code, or browser
&lt;/h2&gt;

&lt;p&gt;There's no single right place, and pretending there is would be dishonest. Reach for whatever's already in front of you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terminal.&lt;/strong&gt; If the file's on disk, &lt;code&gt;jq&lt;/code&gt; is hard to beat: &lt;code&gt;jq . file.json&lt;/code&gt; pretty-prints it, &lt;code&gt;jq -S . file.json&lt;/code&gt; pretty-prints &lt;em&gt;and&lt;/em&gt; sorts keys, and &lt;code&gt;jq -c . file.json&lt;/code&gt; minifies. No &lt;code&gt;jq&lt;/code&gt; installed? &lt;code&gt;python -m json.tool file.json&lt;/code&gt; is on almost every machine and also reports the line and column of the first syntax error, which makes it a decent validator in a pinch. The catch: &lt;code&gt;jq&lt;/code&gt; and &lt;code&gt;python&lt;/code&gt; both refuse anything that isn't already strict JSON, so they're no help with the single-quote-and-&lt;code&gt;True&lt;/code&gt; mess we started with — they'll just report the error, not repair it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editor.&lt;/strong&gt; VS Code and its relatives will format a JSON file with the built-in formatter (Shift+Alt+F, or format-on-save), and they'll underline syntax errors as you type. Great when the file's already open and already close to valid; less great for a stray blob you copied from somewhere and don't want to save as a file first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code.&lt;/strong&gt; Inside a program, it's just &lt;code&gt;JSON.stringify(value, null, 2)&lt;/code&gt; in JavaScript or &lt;code&gt;json.dumps(obj, indent=2, sort_keys=True)&lt;/code&gt; in Python. This is the right tool when formatting has to happen as part of a script or a build step, not as a one-off look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser.&lt;/strong&gt; For the specific job of &lt;em&gt;paste something, see it clean, pull it back out&lt;/em&gt; — which is most of what "format JSON" actually means day to day — a browser tool wins on friction. Nothing to install, it formats on paste, it repairs the almost-JSON cases the terminal tools reject, and it handles the nested-string mess in place. It doesn't replace &lt;code&gt;jq&lt;/code&gt; in a pipeline. It replaces the twelve seconds of ceremony when you just need to read something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why big JSON freezes tabs — and why it doesn't have to
&lt;/h2&gt;

&lt;p&gt;Everyone who works with JSON has killed a browser tab this way: paste a few megabytes of log export into some online formatter, and the page hangs, the fan spins, and you close it and go back to &lt;code&gt;jq&lt;/code&gt;. It happens often enough that plenty of people have quietly written off browser formatters for anything big.&lt;/p&gt;

&lt;p&gt;The usual cause is naive rendering. A lot of web formatters take the whole formatted document and put &lt;em&gt;all of it&lt;/em&gt; into the page's DOM at once — every line, every bracket, every syntax-highlighted span — and then re-do a big chunk of that work on every keystroke. At a few hundred lines you don't notice. At a few hundred thousand, the browser is trying to lay out and paint more DOM nodes than any page should hold, and it stalls.&lt;/p&gt;

&lt;p&gt;The fix is to stop rendering what nobody's looking at. The &lt;a href="https://www.devkitlab.com/en/tools/json-format/" rel="noopener noreferrer"&gt;JSON Formatter&lt;/a&gt; is built on an editor (CodeMirror) that only renders the lines currently in the viewport — scroll, and it builds the rows you just revealed and discards the ones you left. The document can be enormous while the DOM stays small, because the DOM only ever holds the screenful you can actually see. Syntax highlighting is incremental too: an edit re-analyzes the part that changed rather than re-parsing the whole file, and the heavier structural work (like folding every branch at once) runs against a time budget and falls back gracefully rather than freezing the tab. Add fold summaries — a collapsed array shows &lt;code&gt;5 elements&lt;/code&gt;, a collapsed object shows &lt;code&gt;10 keys&lt;/code&gt; — and you can navigate a huge payload by collapsing everything and opening only the branch you care about, without ever laying out the rest.&lt;/p&gt;

&lt;p&gt;None of that makes &lt;code&gt;JSON.parse&lt;/code&gt; itself free; parsing a genuinely huge file still costs something up front. But the difference between "smooth" and "frozen" is almost never the parse. It's what happens to the DOM afterward, and rendering only the viewport is what keeps it smooth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to paste into an online formatter?
&lt;/h2&gt;

&lt;p&gt;Worth pausing on, because the honest answer is "it depends what you're pasting." A lot of the JSON you'd want to format is exactly the sensitive kind — a production API response, a decoded token, an internal config, a webhook body with customer data in it. If a formatter sends that text to a server to do the work, you've just handed a third party a copy, and "it was only for a second" doesn't un-send it.&lt;/p&gt;

&lt;p&gt;The way around it isn't to trust a privacy policy; it's to use a tool that never needs the network. When parsing, repair, formatting, folding, and file reading all run in the browser with JavaScript, the text you paste stays on your machine — nothing is uploaded, so there's nothing to leak. That's the property to look for, and it's the reason a local-first formatter is the safe default for anything you wouldn't post publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas that survive even valid JSON
&lt;/h2&gt;

&lt;p&gt;Two things worth knowing, because they pass every validator and still bite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Big integers lose precision.&lt;/strong&gt; JavaScript numbers are 64-bit floats, and &lt;code&gt;JSON.parse&lt;/code&gt; reads numbers into them. A 64-bit database ID or a Twitter-style snowflake can exceed what a float represents exactly, and it silently rounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id": 9007199254740993}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="c1"&gt;// → 9007199254740992   ← not the number you had&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON was valid. The ID is now wrong by one, quietly, and no error fired. If you're moving large integer IDs through anything JavaScript-based, keep them as strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate keys.&lt;/strong&gt; &lt;code&gt;{"a": 1, "a": 2}&lt;/code&gt; is technically valid JSON, and the spec doesn't say what a parser should do with it. In practice most parsers keep the last one, so this reads back as &lt;code&gt;{"a": 2}&lt;/code&gt; and the first value vanishes without complaint. It's rare, but when a generated document has a duplicated field, this is why one of them seems to disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it back together
&lt;/h2&gt;

&lt;p&gt;Our Slack blob started as a Python dict printout with single quotes, &lt;code&gt;True&lt;/code&gt;/&lt;code&gt;None&lt;/code&gt;, a trailing comma, and a payload hidden inside a string. The path out was the same order every time: read the error to find where it breaks, recognize &lt;em&gt;why&lt;/em&gt; it isn't strict JSON, expand the nested string that was hiding real data, and only then format, sort, or minify depending on what you're doing with it next.&lt;/p&gt;

&lt;p&gt;Formatting is usually step one, not the whole job. Once it's clean, comparing two versions is a job for a structural &lt;a href="https://www.devkitlab.com/en/tools/json-diff/" rel="noopener noreferrer"&gt;JSON Diff&lt;/a&gt;; turning a sample response into typed interfaces is what &lt;a href="https://www.devkitlab.com/en/tools/json-to-types/" rel="noopener noreferrer"&gt;JSON to TypeScript&lt;/a&gt; is for; and pulling one field out of a deep payload without scrolling is what a &lt;a href="https://www.devkitlab.com/en/tools/jsonpath-evaluator/" rel="noopener noreferrer"&gt;JSONPath evaluator&lt;/a&gt; does. Different jobs, but they all start the same way — with JSON that actually parses, and that you can finally read.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devkitlab.com/en/blog/fix-json-that-wont-parse/" rel="noopener noreferrer"&gt;DevKitLab&lt;/a&gt;, which I build and maintain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>json</category>
      <category>programming</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why Isn't My Regex Matching? Start by Understanding the Engine</title>
      <dc:creator>devkitlab</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:50:55 +0000</pubDate>
      <link>https://dev.to/devkitlab/why-isnt-my-regex-matching-start-by-understanding-the-engine-3d77</link>
      <guid>https://dev.to/devkitlab/why-isnt-my-regex-matching-start-by-understanding-the-engine-3d77</guid>
      <description>&lt;p&gt;You write a regex, stare at it for a while, and the longer you look the more certain you are that it &lt;em&gt;should&lt;/em&gt; match. But it matches nothing — or it matches something wildly different from what you wanted. So you start piling things on: another &lt;code&gt;.*&lt;/code&gt;, another &lt;code&gt;\&lt;/code&gt;, one more set of parentheses around that group. Each addition nudges it further from correct.&lt;/p&gt;

&lt;p&gt;Here's the thing most regex tutorials won't lead with: the engine is never wrong. It did exactly what you wrote, letter for letter — it's just that what you wrote isn't what you meant. Which makes "why isn't it matching?" the wrong question. The more useful one is: &lt;strong&gt;where is the engine right now, what did it try, and why did it stop there?&lt;/strong&gt; Once you can think the way the engine does, every baffling result collapses into something predictable.&lt;/p&gt;

&lt;p&gt;This article isn't a list of tips. It spends a few minutes on how the engine works, then evolves a single example all the way through — from a pattern that's wildly wrong to one that's correct, robust, and won't tank your performance. Greedy vs. lazy, the flag you forgot to turn on, the &lt;code&gt;lastIndex&lt;/code&gt; gotcha that makes results flip-flop, anchors, the two worlds of escaping, zero-width lookahead, the three layers of Unicode, and finally the catastrophic backtracking that can peg your CPU — you'll see they're all facets of the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the engine is actually doing
&lt;/h2&gt;

&lt;p&gt;JavaScript — like PCRE, Python's &lt;code&gt;re&lt;/code&gt;, Java, and most engines you touch day to day — is a &lt;strong&gt;backtracking&lt;/strong&gt; engine. The way it works is honestly pretty simple: the engine keeps a "current position," starts at the far left of the string, and reads one character at a time moving right, trying at each position to see whether your pattern can match starting from there.&lt;/p&gt;

&lt;p&gt;The whole game is in that word &lt;em&gt;try&lt;/em&gt;. Every time the engine hits a quantifier like &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, or &lt;code&gt;?&lt;/code&gt;, it faces a choice: eat one more character, or stop here? A &lt;strong&gt;greedy&lt;/strong&gt; quantifier (the default &lt;code&gt;*&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt;) always chooses "eat more," swallowing as much as it can — while noting down every "I could have stopped here" position on a &lt;strong&gt;backtracking stack&lt;/strong&gt;. When the pattern later gets stuck somewhere down the line, the engine doesn't give up immediately; it rewinds to the most recently noted position, &lt;strong&gt;spits one character back out&lt;/strong&gt;, and tries a different way forward. Only when it has exhausted every possibility at the current position does it concede, shift the current position one to the right, and start over from scratch.&lt;/p&gt;

&lt;p&gt;This one move — &lt;strong&gt;eat greedily, and when you get stuck, backtrack by spitting back out&lt;/strong&gt; — is the master key to reading regex behavior. You'll see it again and again below: greedy overshoots because it swallows first and gives back later; lazy does the opposite because it holds off until forced; lookahead doesn't consume characters because it only peeks and doesn't actually advance; catastrophic backtracking pegs the CPU because the number of "try a different way" combinations explodes exponentially. One machine, one set of rules.&lt;/p&gt;

&lt;p&gt;Worth knowing in passing: there's a &lt;em&gt;different&lt;/em&gt; class of engine that doesn't backtrack — like Go's &lt;code&gt;regexp&lt;/code&gt; and Rust's &lt;code&gt;regex&lt;/code&gt; (the one behind &lt;code&gt;ripgrep&lt;/code&gt;). They're built on finite automata that turn matching into a single linear scan, which makes them immune to catastrophic backtracking by construction — at the cost of dropping features like backreferences that inherently need backtracking. That explains a common puzzle: the same dangerous pattern runs blazing fast under &lt;code&gt;ripgrep&lt;/code&gt; yet can hang a Node.js process. &lt;strong&gt;Which class your engine is in decides which pitfalls even apply to you.&lt;/strong&gt; This article is about JavaScript's backtracking engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The string we're fixing
&lt;/h2&gt;

&lt;p&gt;The whole article aims at one target, and it's close to something real — two links sitting side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/products/12"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Boots&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/products/34"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Hat&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is modest: &lt;strong&gt;pull out both &lt;code&gt;href&lt;/code&gt; values&lt;/strong&gt;, i.e. &lt;code&gt;/products/12&lt;/code&gt; and &lt;code&gt;/products/34&lt;/code&gt;. It's simple enough that you'd expect to nail it in one shot, yet it manages to draw out every quirk of the engine above. Before touching anything, remember one thing: the first step in debugging a regex is never to change the pattern — it's to &lt;em&gt;see where it currently lands&lt;/em&gt;. Put the pattern, the flags, and the test text together and let it highlight each matched span right on the original — that's exactly what the &lt;a href="https://www.devkitlab.com/en/tools/regex-tester/" rel="noopener noreferrer"&gt;regex tester&lt;/a&gt; is for. It turns an abstract pattern into a stretch of colored text your eyes can read. In every section below, you can paste that section's pattern in and see with your own eyes which span it actually matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Greedy: it didn't fail to match, it backtracked too late
&lt;/h2&gt;

&lt;p&gt;Here's a first version almost everyone has written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"(.*)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You expect &lt;code&gt;(.*)&lt;/code&gt; to capture &lt;code&gt;/products/12&lt;/code&gt;. What it actually captures is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;/products/12"&amp;gt;Boots&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/products/34
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From just after the first &lt;code&gt;href="&lt;/code&gt;, it eats all the way to just before the &lt;strong&gt;last&lt;/strong&gt; &lt;code&gt;"&amp;gt;&lt;/code&gt;. Through the engine's lens, this is no mystery at all: &lt;code&gt;.*&lt;/code&gt; greedily swallowed everything from here to the end of the line in one gulp, then the engine noticed the pattern still owed one &lt;code&gt;"&amp;gt;&lt;/code&gt;, so it began backtracking — spitting characters back out one at a time, from the right edge leftward. By the time it had backed up to the &lt;em&gt;last&lt;/em&gt; &lt;code&gt;"&amp;gt;&lt;/code&gt;, the pattern was satisfied and declared success, so it stopped there and never gave anything more back. It didn't "want to eat that much" — it backtracked too late to hit the first place it could close.&lt;/p&gt;

&lt;p&gt;The fix is to add a &lt;code&gt;?&lt;/code&gt; to the quantifier, flipping it from greedy to &lt;strong&gt;lazy&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"(.*?)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.*?&lt;/code&gt; reverses the default choice — at each step it first picks "don't eat," and only grudgingly swallows one character when the next step won't work otherwise. So it starts from empty, expands a little at a time, and stops at the &lt;em&gt;first&lt;/em&gt; &lt;code&gt;"&amp;gt;&lt;/code&gt;, leaving &lt;code&gt;(.*?)&lt;/code&gt; capturing exactly &lt;code&gt;/products/12&lt;/code&gt;. Paste both patterns into a regex tester in turn and you'll watch the highlight snap back — the most direct look you'll get at greedy vs. lazy.&lt;/p&gt;

&lt;p&gt;That said, the cleanest version is neither greedy nor lazy — it's the one that &lt;strong&gt;never gives the engine room to backtrack in the first place&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"([^"&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="na"&gt;*&lt;/span&gt;&lt;span class="err"&gt;)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[^"]*&lt;/code&gt; means "any character except a double quote." It can't reach that closing quote to begin with, so it naturally stops right before it — no eating too much and giving it back, no backtracking at all. That's not just faster; it's safer. And here's a JavaScript hard limit worth knowing early: some engines offer &lt;strong&gt;atomic groups&lt;/strong&gt; &lt;code&gt;(?&amp;gt;...)&lt;/code&gt; or &lt;strong&gt;possessive quantifiers&lt;/strong&gt; &lt;code&gt;a++&lt;/code&gt; that explicitly tell the engine "once you've eaten this, never give it back," locking out backtracking at the root. JavaScript to this day has &lt;strong&gt;neither&lt;/strong&gt;. So in JS, the only lever you have over backtracking is exactly this — &lt;strong&gt;rewrite the pattern into a shape with no ambiguity&lt;/strong&gt;, using a negated character class to nail down the boundary instead of leaning on &lt;code&gt;.*?&lt;/code&gt;. Hold onto this; it's the cure when we get to catastrophic backtracking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flags and state: the pattern is fine, it's the engine's switches and memory
&lt;/h2&gt;

&lt;p&gt;Say you've moved to &lt;code&gt;&amp;lt;a href="([^"]*)"&amp;gt;&lt;/code&gt;, but it only matches the &lt;strong&gt;first&lt;/strong&gt; link; the second, &lt;code&gt;/products/34&lt;/code&gt;, refuses to show up. Nothing's wrong with the pattern — the problem is a flag: one of those switches that default to off, don't complain when you forget them, and quietly hand you half a result.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;g&lt;/code&gt; (global)&lt;/strong&gt; — it makes &lt;code&gt;replace&lt;/code&gt; handle every hit, and it's the prerequisite for &lt;code&gt;matchAll&lt;/code&gt;. It also changes what &lt;code&gt;String.prototype.match()&lt;/code&gt; returns: without &lt;code&gt;g&lt;/code&gt; you get the first match plus its capture groups; with &lt;code&gt;g&lt;/code&gt; you get all the full matches. To iterate every hit &lt;em&gt;and&lt;/em&gt; its groups reliably, &lt;code&gt;matchAll&lt;/code&gt; is the clearest option. Your only match above was the first because &lt;code&gt;g&lt;/code&gt; was off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;i&lt;/code&gt; (ignore case)&lt;/strong&gt; — &lt;code&gt;/hat/&lt;/code&gt; won't match &lt;code&gt;Hat&lt;/code&gt;; turn on &lt;code&gt;i&lt;/code&gt; and &lt;code&gt;HAT&lt;/code&gt;, &lt;code&gt;Hat&lt;/code&gt;, &lt;code&gt;hat&lt;/code&gt; are all equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;m&lt;/code&gt; (multiline)&lt;/strong&gt; — it only changes what &lt;code&gt;^&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; mean; next section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;s&lt;/code&gt; (dotall)&lt;/strong&gt; — by default &lt;code&gt;.&lt;/code&gt; &lt;strong&gt;does not match a newline&lt;/strong&gt;. If your pattern has a &lt;code&gt;.*&lt;/code&gt; and the content spans lines, &lt;code&gt;.&lt;/code&gt; stops at the newline; turn on &lt;code&gt;s&lt;/code&gt; and it treats newlines as matchable too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's all still common knowledge. But &lt;code&gt;g&lt;/code&gt; hides a deeper trap: &lt;strong&gt;a regex with &lt;code&gt;g&lt;/code&gt; (or &lt;code&gt;y&lt;/code&gt;) is stateful.&lt;/strong&gt; The same &lt;code&gt;RegExp&lt;/code&gt; object remembers a &lt;code&gt;lastIndex&lt;/code&gt; — where the previous match ended — and picks up from there next time. Feed the same object to &lt;code&gt;.test()&lt;/code&gt; or &lt;code&gt;.exec()&lt;/code&gt; repeatedly and you get this seemingly impossible result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/href/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// true —— lastIndex advances to 4&lt;/span&gt;
&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// false —— starts looking from position 4, nothing there&lt;/span&gt;
&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// true —— hit the end, lastIndex resets to 0, starts over&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same string, same regex, yet &lt;code&gt;test&lt;/code&gt; flip-flops between &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt;. The culprit is that silently rewritten &lt;code&gt;lastIndex&lt;/code&gt;. Reusing a &lt;code&gt;g&lt;/code&gt; regex isn't wrong in itself — that's exactly how you'd deliberately walk matches with &lt;code&gt;exec()&lt;/code&gt; — but before a &lt;code&gt;.test()&lt;/code&gt; check that doesn't need to continue from a position, reset &lt;code&gt;lastIndex = 0&lt;/code&gt; or build a fresh regex. Above all, don't hoist it into a module-level constant and then &lt;code&gt;test&lt;/code&gt; it all over the place. To read every match with its groups, prefer &lt;code&gt;String.prototype.matchAll()&lt;/code&gt;; it derives an iterator from the original and never touches that object's &lt;code&gt;lastIndex&lt;/code&gt;. One aside: the tester above enumerates every hit in its matches list for readability, while the replace preview still honors whether you've turned on &lt;code&gt;g&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anchors and boundaries: they match a position, not a character
&lt;/h2&gt;

&lt;p&gt;Anchors are a special case in the backtracking model — they're &lt;strong&gt;zero-width&lt;/strong&gt;, matching the seam &lt;em&gt;between&lt;/em&gt; characters rather than a character itself. That's where they're most counterintuitive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;^&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; anchor to the start and end of the &lt;strong&gt;whole string&lt;/strong&gt; by default, not each line. Take this multiline text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: disk full
error: timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You write &lt;code&gt;^error:&lt;/code&gt; hoping to match the start of each line, but by default &lt;code&gt;^&lt;/code&gt; only recognizes that one position at the very start of the whole string, so only the first line hits. To make &lt;code&gt;^&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; fire at the start and end of &lt;strong&gt;every line&lt;/strong&gt;, turn on &lt;strong&gt;&lt;code&gt;m&lt;/code&gt; (multiline)&lt;/strong&gt;. These two almost always come as a pair; forgetting &lt;code&gt;m&lt;/code&gt; is the number one reason for "why does it only match the first line?"&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\b&lt;/code&gt; (word boundary) is another commonly misread zero-width assertion. It matches the seam "between a word character and a non-word character." &lt;code&gt;\bcat\b&lt;/code&gt; captures &lt;code&gt;cat&lt;/code&gt; in &lt;code&gt;a cat sat&lt;/code&gt;, but &lt;strong&gt;won't&lt;/strong&gt; match the &lt;code&gt;cat&lt;/code&gt; in &lt;code&gt;category&lt;/code&gt; — because &lt;code&gt;cat&lt;/code&gt; is immediately followed by &lt;code&gt;e&lt;/code&gt;, and there's no boundary there. Plenty of people treat &lt;code&gt;\b&lt;/code&gt; as "a space," but punctuation and the start/end of a line count as boundaries too. And there's a deeper trap: &lt;code&gt;\b&lt;/code&gt; decides "what counts as a word character" using the ASCII definition (&lt;code&gt;[A-Za-z0-9_]&lt;/code&gt;), &lt;strong&gt;even when you've turned on &lt;code&gt;u&lt;/code&gt;&lt;/strong&gt;. So for Chinese, or accented Latin letters, &lt;code&gt;\b&lt;/code&gt; will disagree with your intuition — a Han character isn't a "word character" in its eyes, so the boundary lands in the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escaping: one dot, two worlds
&lt;/h2&gt;

&lt;p&gt;In a regex, &lt;code&gt;. + * ? ( ) [ ] { } ^ $ | \&lt;/code&gt; all carry special meaning; to match them &lt;strong&gt;literally&lt;/strong&gt; you have to escape them with &lt;code&gt;\&lt;/code&gt;. The most common victim is the dot: you want to match the &lt;code&gt;.&lt;/code&gt; in a version number, so you write &lt;code&gt;\d+\.\d+&lt;/code&gt;; get lazy and write &lt;code&gt;\d+.\d+&lt;/code&gt;, and that bare &lt;code&gt;.&lt;/code&gt; becomes "any character," so &lt;code&gt;1X2&lt;/code&gt;, &lt;code&gt;1 2&lt;/code&gt;, &lt;code&gt;1a2&lt;/code&gt; all match — far looser than you wanted, with no error, quietly matching things it shouldn't the day some dirty data comes through.&lt;/p&gt;

&lt;p&gt;But what really drives people up the wall is the &lt;strong&gt;two worlds of escaping&lt;/strong&gt;. A tester's input box takes the &lt;strong&gt;raw regex&lt;/strong&gt;, where one backslash is one &lt;code&gt;\&lt;/code&gt;; but a regex in code often lives inside a string, and the string itself also uses &lt;code&gt;\&lt;/code&gt; for escaping, so the same pattern is written differently in the two worlds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a regex literal, one backslash is enough:&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// But built from a string, the backslashes double — the string eats one layer first:&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;d+&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;d+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"It works fine in the tester and breaks the moment I put it in code" is, nine times out of ten, this layer of backslashes not lining up — the &lt;code&gt;\\d&lt;/code&gt; you copied out of some JSON or a log field should actually be &lt;code&gt;\d&lt;/code&gt; when pasted into the tester, and doubled back up when returned to a code string. The tester's export feature handles that difference for you, giving you the paste-ready form in the target language and sparing you the manual backslash-counting.&lt;/p&gt;

&lt;p&gt;There's an advanced case too: when you build a regex from a &lt;strong&gt;variable&lt;/strong&gt; — say, treating user input as a literal string to match — any &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, or &lt;code&gt;?&lt;/code&gt; in that input gets read as a metacharacter, which at best matches wrong and at worst lets external input rewrite your matching logic. &lt;code&gt;RegExp.escape()&lt;/code&gt; was standardized in ES2025 and safely turns a string into a literal-match fragment; modern browsers support it, but if you need to support older browsers or runtimes, check your target environment first and reach for a maintained polyfill if necessary. &lt;strong&gt;When you build a regex dynamically, the variable part must be escaped first&lt;/strong&gt; — never drop an external string straight into the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lookahead and lookbehind: they check a condition, they don't consume characters
&lt;/h2&gt;

&lt;p&gt;At this point you might want something more precise: pull out the &lt;code&gt;href&lt;/code&gt; value &lt;strong&gt;without&lt;/strong&gt; the surrounding quotes. Lookahead and lookbehind exist for exactly this, and they carry a misconception almost everyone falls for.&lt;/p&gt;

&lt;p&gt;Back to the engine model: a lookahead &lt;code&gt;(?=...)&lt;/code&gt; makes the engine "peek forward" to check whether what follows matches some pattern — &lt;strong&gt;but after that peek, the current position does not move forward&lt;/strong&gt;. It's &lt;strong&gt;zero-width&lt;/strong&gt;: whether the condition holds affects success or failure, but the characters it checks &lt;strong&gt;don't count toward the match result&lt;/strong&gt;. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\d+(?=px)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;12px&lt;/code&gt;, it matches &lt;code&gt;12&lt;/code&gt;, &lt;strong&gt;not including &lt;code&gt;px&lt;/code&gt;&lt;/strong&gt;. If you print &lt;code&gt;match[0]&lt;/code&gt; and find no &lt;code&gt;px&lt;/code&gt; in it, that's not a bug — that's the definition of lookahead. Likewise negative lookahead &lt;code&gt;(?!...)&lt;/code&gt;, lookbehind &lt;code&gt;(?&amp;lt;=...)&lt;/code&gt;, and negative lookbehind &lt;code&gt;(?&amp;lt;!...)&lt;/code&gt; are all zero-width — they frame the context without consuming it.&lt;/p&gt;

&lt;p&gt;This "doesn't take up characters" property is exactly what makes them valuable. To pull out the value between the quotes without the quotes, you can write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(?&amp;lt;=href=")[^"]*(?=")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lookbehind asserts "I'm preceded by &lt;code&gt;href="&lt;/code&gt;," the lookahead asserts "I'm followed by &lt;code&gt;"&lt;/code&gt;," and the &lt;code&gt;[^"]*&lt;/code&gt; in the middle is the only part actually consumed and returned — the quotes are conditions only, not results, sparing you a later &lt;code&gt;slice&lt;/code&gt; on both ends. Paste it into the tester and you'll see it vividly: the condition regions aren't highlighted, only the actually-consumed span is colored — the abstract word "zero-width" made concrete at a glance. (One JavaScript bonus: its lookbehind supports &lt;strong&gt;variable-length&lt;/strong&gt; patterns, unlike some engines that allow only fixed-length lookbehind, so &lt;code&gt;(?&amp;lt;=\w+=")&lt;/code&gt; is valid in JS.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The three layers of Unicode: ASCII, code points, grapheme clusters
&lt;/h2&gt;

&lt;p&gt;The moment your text contains Chinese, emoji, or any non-ASCII character, a whole layer of pitfalls is waiting — and it's deeper than most people assume. "Unicode support" actually comes in three layers, and every one of them can bite you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer one: stop assuming &lt;code&gt;\d&lt;/code&gt; and &lt;code&gt;\w&lt;/code&gt; know about non-ASCII.&lt;/strong&gt; In JavaScript, &lt;code&gt;\d&lt;/code&gt; matches only ASCII &lt;code&gt;0-9&lt;/code&gt; by default and &lt;code&gt;\w&lt;/code&gt; recognizes only &lt;code&gt;[A-Za-z0-9_]&lt;/code&gt;, so &lt;code&gt;\w+&lt;/code&gt; can't capture a single Han character. To match by Unicode category, turn on the &lt;strong&gt;&lt;code&gt;u&lt;/code&gt;&lt;/strong&gt; flag and switch to &lt;code&gt;\p{...}&lt;/code&gt; property escapes: &lt;code&gt;\p{L}&lt;/code&gt; is a letter in any language, &lt;code&gt;\p{N}&lt;/code&gt; is a number, &lt;code&gt;\p{Han}&lt;/code&gt; is Han characters specifically, and &lt;code&gt;/\p{L}+/u&lt;/code&gt; can finally match a Chinese word. Here's the &lt;strong&gt;deeply counterintuitive&lt;/strong&gt; detail: turning on &lt;code&gt;u&lt;/code&gt; &lt;strong&gt;won't&lt;/strong&gt; make &lt;code&gt;\d&lt;/code&gt;, &lt;code&gt;\w&lt;/code&gt;, or &lt;code&gt;\b&lt;/code&gt; understand Unicode — they stay ASCII-semantic. To match "any digit," including full-width and Arabic-Indic ones, you have to write &lt;code&gt;\p{Nd}&lt;/code&gt;, not hope that &lt;code&gt;u&lt;/code&gt; upgrades &lt;code&gt;\d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer two: &lt;code&gt;u&lt;/code&gt; makes the engine walk by code point rather than UTF-16 code unit.&lt;/strong&gt; Without &lt;code&gt;u&lt;/code&gt;, a character that occupies two code units (like many emoji) gets sliced in half by &lt;code&gt;.&lt;/code&gt;, and what you cut out is garbage. With &lt;code&gt;u&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt; handles complete code points, and a notation like &lt;code&gt;\u{1F600}&lt;/code&gt; is finally recognized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer three, the one most often overlooked: a code point still isn't "one character."&lt;/strong&gt; What the eye sees as a single character is technically a &lt;strong&gt;grapheme cluster&lt;/strong&gt;, which can be assembled from several code points — a skin-toned emoji, a flag, a family emoji like &lt;code&gt;👨‍👩‍👧&lt;/code&gt; glued together with zero-width joiners, are all combinations of multiple code points. Even with &lt;code&gt;u&lt;/code&gt; on, &lt;code&gt;.&lt;/code&gt; matches only &lt;em&gt;one&lt;/em&gt; of those code points, so you can still shred an emoji. To handle things as "characters the way people see them," there are two roads: ES2024's &lt;strong&gt;&lt;code&gt;v&lt;/code&gt;&lt;/strong&gt; flag (&lt;code&gt;unicodeSets&lt;/code&gt;) brings set operations and string properties like &lt;code&gt;\p{RGI_Emoji}&lt;/code&gt; that can match a composite emoji as a whole; and for general grapheme segmentation, use &lt;code&gt;Intl.Segmenter&lt;/code&gt; — it's not a regex, but it's the better fit for splitting text along human perception. That tester currently offers &lt;code&gt;u&lt;/code&gt; and not a &lt;code&gt;v&lt;/code&gt; toggle; if you need &lt;code&gt;v&lt;/code&gt;, verify it in your target environment. &lt;strong&gt;When you work with emoji or multilingual text, decide up front which layer you want&lt;/strong&gt; — it saves you a whole class of "matched half a character" problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  It matches correctly, yet freezes the page: catastrophic backtracking
&lt;/h2&gt;

&lt;p&gt;The last kind of "wrong" isn't a wrong match — it's the match &lt;strong&gt;hanging the moment it runs&lt;/strong&gt;: the tab goes unresponsive, the CPU pegs. This is &lt;strong&gt;catastrophic backtracking&lt;/strong&gt;, and it's the mechanism behind ReDoS denial-of-service attacks. With the engine model from earlier, you can now understand it from first principles.&lt;/p&gt;

&lt;p&gt;The classic form is &lt;strong&gt;nested quantifiers&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(a+)+$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against a string of &lt;code&gt;aaaaaaaaaaX&lt;/code&gt; (whose ending is deliberately not what it wants). The inner &lt;code&gt;a+&lt;/code&gt; and outer &lt;code&gt;+&lt;/code&gt; can match the same batch of characters, so that run of a's has an &lt;strong&gt;exponential&lt;/strong&gt; number of ways to be split into groups: &lt;code&gt;(a)(a)(a)…&lt;/code&gt;, &lt;code&gt;(aa)(a)…&lt;/code&gt;, &lt;code&gt;(a)(aa)…&lt;/code&gt;, and so on — and that trailing &lt;code&gt;$&lt;/code&gt; can never hold before &lt;code&gt;X&lt;/code&gt;, forcing the engine to backtrack through every single split before it dares declare failure. Make the input a little longer and the time it takes to fail climbs steeply; exactly how long depends on the browser, hardware, and engine implementation, so you can't pin it to a fixed character count. The culprit's profile is fixed: &lt;strong&gt;one quantifier wrapped around another, where both can match the same batch of characters&lt;/strong&gt; — &lt;code&gt;(a+)+&lt;/code&gt;, &lt;code&gt;(.*)*&lt;/code&gt;, &lt;code&gt;(\d+)*&lt;/code&gt; are all in this family; &lt;code&gt;(.*?)*&lt;/code&gt;, nesting lazy inside greedy, is just as dangerous.&lt;/p&gt;

&lt;p&gt;This is exactly the payoff of that earlier setup: why, back at greedy, I urged you to &lt;strong&gt;use &lt;code&gt;[^"]*&lt;/code&gt; rather than &lt;code&gt;.*?&lt;/code&gt;&lt;/strong&gt;. A negated character class draws the boundary up front, with no need to probe back and forth across every position. JavaScript currently has no atomic groups or possessive quantifiers — the two syntaxes that directly cap backtracking — so the go-to move is to &lt;strong&gt;rewrite the pattern to remove the ambiguity&lt;/strong&gt;: keep each branch's match range from overlapping, add anchors at the right spots, swap the boundless &lt;code&gt;.*&lt;/code&gt; for an explicit character class. The &lt;a href="https://www.devkitlab.com/en/tools/regex-tester/" rel="noopener noreferrer"&gt;regex tester&lt;/a&gt; checks for potential ReDoS risk asynchronously; when it detects risk it shows an attack sample, and when it can't complete the judgment it says so clearly. It's good for catching problems early, but it's no substitute for a performance test at real input scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can actually follow
&lt;/h2&gt;

&lt;p&gt;Next time a regex misbehaves, don't rush to pile on characters. Go back to the engine's point of view and run this order — it locates the problem almost every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First see where it currently matches.&lt;/strong&gt; Paste the pattern and text into the &lt;a href="https://www.devkitlab.com/en/tools/regex-tester/" rel="noopener noreferrer"&gt;regex tester&lt;/a&gt; and look at the highlight; don't guess. Eight times out of ten the problem shows itself right here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overshooting?&lt;/strong&gt; A greedy &lt;code&gt;.*&lt;/code&gt; backtracks too late — change it to &lt;code&gt;.*?&lt;/code&gt;, or better, the negated character class &lt;code&gt;[^…]*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only matching the first one / first line?&lt;/strong&gt; You probably forgot to turn on &lt;code&gt;g&lt;/code&gt; or &lt;code&gt;m&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same code, works then doesn't?&lt;/strong&gt; Check whether you're reusing a &lt;code&gt;g&lt;/code&gt; regex and &lt;code&gt;lastIndex&lt;/code&gt; is at play.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.&lt;/code&gt; &lt;code&gt;(&lt;/code&gt; &lt;code&gt;[&lt;/code&gt; not matching literally?&lt;/strong&gt; Escape what needs escaping with &lt;code&gt;\&lt;/code&gt;, and make sure the backslashes weren't eaten or doubled at the string layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result has more or less than you expected?&lt;/strong&gt; Consider whether you've treated a zero-width lookahead/lookbehind as an ordinary group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chinese or emoji not matching, or getting shredded?&lt;/strong&gt; Separate the three layers — ASCII, code point, grapheme cluster: turn on &lt;code&gt;u&lt;/code&gt;, use &lt;code&gt;\p{...}&lt;/code&gt;, and reach for the &lt;code&gt;v&lt;/code&gt; flag or &lt;code&gt;Intl.Segmenter&lt;/code&gt; when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hangs the moment it runs?&lt;/strong&gt; Look for nested quantifiers — that's catastrophic backtracking, solved in JS by rewriting the pattern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Getting the regex right is often where the work begins. Batch-replacing what you matched into some other shape is what the &lt;a href="https://www.devkitlab.com/en/tools/text-replace/" rel="noopener noreferrer"&gt;text replacer&lt;/a&gt; does with regex capture groups; confirming exactly what changed between two versions of text is a job for the &lt;a href="https://www.devkitlab.com/en/tools/text-compare/" rel="noopener noreferrer"&gt;text comparison tool&lt;/a&gt;. But every step starts from the same place — a regex whose &lt;strong&gt;matches, and the reason for each step it takes, you can see clearly&lt;/strong&gt; — not one you can only stare at and guess about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devkitlab.com/en/blog/why-isnt-my-regex-matching/" rel="noopener noreferrer"&gt;DevKitLab&lt;/a&gt;, which I build and maintain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>regex</category>
    </item>
    <item>
      <title>Where Should I Store a JWT? localStorage, Cookies, and the XSS/CSRF Trade-off</title>
      <dc:creator>devkitlab</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:15:55 +0000</pubDate>
      <link>https://dev.to/devkitlab/where-should-i-store-a-jwt-localstorage-cookies-and-the-xsscsrf-trade-off-3co3</link>
      <guid>https://dev.to/devkitlab/where-should-i-store-a-jwt-localstorage-cookies-and-the-xsscsrf-trade-off-3co3</guid>
      <description>&lt;p&gt;The first two articles in this series were about the server: how to read a token, and &lt;a href="https://www.devkitlab.com/en/blog/is-my-jwt-secure/" rel="noopener noreferrer"&gt;how to verify one without being fooled&lt;/a&gt;. This one moves to the browser. Once your single-page app receives a JWT, it has to keep it somewhere, and the question of &lt;em&gt;where&lt;/em&gt; turns out to be one of the most argued-about topics in web auth — usually for the wrong reason.&lt;/p&gt;

&lt;p&gt;It's argued as if the answer were a ranking, with one storage location that is simply "more secure" than the others. It isn't. Every option is defined by how it answers two independent questions: &lt;strong&gt;can JavaScript running on your page read the token&lt;/strong&gt;, and &lt;strong&gt;does the browser attach it to requests automatically&lt;/strong&gt;. The first question is about cross-site scripting (XSS); the second is about cross-site request forgery (CSRF). Each storage choice answers them differently, which means the right answer depends on which attack you're more exposed to — and, above everything else, on whether you can keep XSS out of your page at all. That last point is the one most of these debates bury, so we'll build up to it deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different attackers
&lt;/h2&gt;

&lt;p&gt;Before comparing storage, it's worth separating the two threats cleanly, because they get conflated constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XSS&lt;/strong&gt; is when an attacker gets their JavaScript running inside your origin — through an unescaped piece of user input, a compromised third-party script, or a vulnerable dependency. Once that happens, their code runs with exactly the privileges your own code has. It can read anything your scripts can read and call any endpoint your app can call. XSS is code execution in your page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSRF&lt;/strong&gt; is different and, in a way, more limited: the attacker's own site causes the victim's browser to fire an authenticated request at your API. It works because the browser &lt;em&gt;automatically&lt;/em&gt; attaches ambient credentials — most often cookies — to requests for your domain, even when the request originates from &lt;code&gt;evil.example&lt;/code&gt;. The attacker never sees the token and never sees the response; they simply cause an action to happen using credentials the browser volunteers on their behalf.&lt;/p&gt;

&lt;p&gt;Keep that distinction in hand: XSS reads and acts from &lt;em&gt;inside&lt;/em&gt; your page; CSRF forges a request from &lt;em&gt;outside&lt;/em&gt; it and relies on the browser bringing the credentials along. The storage options line up almost entirely along these two axes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options, and what each one exposes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;localStorage and sessionStorage.&lt;/strong&gt; This is the default choice in most SPA tutorials, and it has one genuine strength and one fatal weakness. It is readable by any JavaScript on the page, so a single line — &lt;code&gt;localStorage.getItem('token')&lt;/code&gt; — hands the token to an XSS payload, which can then ship it off to an attacker's server to be replayed anywhere, anytime, until it expires. That is the fatal weakness. The genuine strength is the mirror image: because the value is only ever attached to requests by your own code (as an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header), the browser never sends it automatically, so a cross-site request can't carry it. localStorage is, in other words, &lt;strong&gt;CSRF-immune and XSS-fatal&lt;/strong&gt;. (&lt;code&gt;sessionStorage&lt;/code&gt; differs only in lifetime — per-tab, cleared when the tab closes — not in its security profile.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A regular, non-&lt;code&gt;httpOnly&lt;/code&gt; cookie used to hold the token.&lt;/strong&gt; As a place to keep the credential this is the worst of both worlds: JavaScript can read it &lt;em&gt;and&lt;/em&gt; the browser sends it automatically, so you inherit the XSS exposure of localStorage and the CSRF exposure of cookies at once. (Non-&lt;code&gt;httpOnly&lt;/code&gt; cookies are perfectly reasonable for other jobs — a language preference, non-sensitive UI state, a double-submit CSRF token — just not for holding the token itself.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An &lt;code&gt;httpOnly&lt;/code&gt; cookie.&lt;/strong&gt; The &lt;code&gt;httpOnly&lt;/code&gt; flag makes a cookie invisible to JavaScript, so &lt;code&gt;document.cookie&lt;/code&gt; can't see it and an XSS payload can't read the token out to exfiltrate it. That is a real and valuable property. In exchange, the browser sends the cookie automatically on requests to its domain — subject to its &lt;code&gt;Domain&lt;/code&gt;, &lt;code&gt;Path&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, and &lt;code&gt;SameSite&lt;/code&gt; scoping — which puts you squarely in CSRF territory and obliges you to add CSRF defenses (more on those below). But there's a subtlety here that a lot of advice gets wrong, and it deserves its own paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;httpOnly&lt;/code&gt; stops theft, not abuse
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;httpOnly&lt;/code&gt; flag prevents an XSS payload from &lt;em&gt;reading&lt;/em&gt; the token. It does not prevent that same payload from &lt;em&gt;using&lt;/em&gt; it. An attacker running JavaScript in your page can simply make authenticated requests from the page itself — call your "change email" endpoint, mint an API key, transfer whatever your app can transfer — and the browser will attach the &lt;code&gt;httpOnly&lt;/code&gt; cookie to each of those requests exactly as it would for legitimate code. The attacker can't steal the token to replay it later or from another machine, but they can act as the user, right now, for as long as the page is open.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;httpOnly&lt;/code&gt; genuinely helps: it bounds the blast radius by stopping exfiltration, which means no long-lived stolen credential and no replay from the attacker's own infrastructure. But it does not neutralize XSS. This is the crucial correction to the common belief that "&lt;code&gt;httpOnly&lt;/code&gt; cookies make you XSS-safe." They make you &lt;em&gt;harder to rob&lt;/em&gt;, not immune to being impersonated while compromised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-memory storage&lt;/strong&gt; — a plain JavaScript variable or a module-level closure — rounds out the set. Because nothing is written to disk, there's no persistent copy to steal; when the tab reloads or closes, the token is gone. It isn't auto-attached, so it's CSRF-immune like localStorage, and it's narrower than localStorage against theft in one respect: there's no stored copy to exfiltrate and replay later. That's a real but limited benefit — a closure variable may not even be directly reachable by injected code, yet an XSS in your origin can still call authenticated endpoints, hook into your request path, or capture a fresh token during the refresh flow. In-memory storage frustrates &lt;em&gt;offline replay&lt;/em&gt;; it does not stop same-origin XSS from abusing the session. The cost is UX: the token vanishes on every refresh, so you need a way to silently obtain a fresh one — which is precisely the refresh-token machinery of the next article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The truth the debate buries: XSS beats storage
&lt;/h2&gt;

&lt;p&gt;Line those up and a pattern emerges that reframes the whole question. If an attacker can run JavaScript in your origin, &lt;strong&gt;no client-side storage location fully protects the token.&lt;/strong&gt; In localStorage it's stolen outright. In an &lt;code&gt;httpOnly&lt;/code&gt; cookie it can't be read, but the attacker rides it to act as the user anyway. In memory there may be no directly readable copy, but XSS can still ride the session — calling authenticated endpoints, or capturing a token as it's refreshed. There is no arrangement of browser storage that is safe against an attacker already executing code on your page.&lt;/p&gt;

&lt;p&gt;That leads to the honest hierarchy of controls, and it's the opposite of how the question usually gets asked. &lt;strong&gt;Preventing XSS is the primary defense; the storage choice only decides how much worse a successful XSS gets.&lt;/strong&gt; Anyone who tells you "put the token in X and XSS can't hurt you" is overselling — storage is damage-limitation, not prevention. So the work that actually protects your users lives mostly outside the storage decision: rely on your framework's automatic output escaping (React, Vue, and Angular all escape by default) and treat every &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; or &lt;code&gt;v-html&lt;/code&gt; on untrusted data as a liability; ship a strict Content-Security-Policy, which both limits what injected script can do and restricts where it could exfiltrate a stolen token; consider Trusted Types to choke off DOM-based sinks; pin the third-party static assets you can version and hash with Subresource Integrity (it verifies a known file, so it's a spot control, not a general supply-chain fix); and keep dependencies patched, since a single compromised package in your build is an XSS with your app's full privileges. Storage choice is a real decision, but it's the second one, not the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you use cookies, deal with CSRF properly
&lt;/h2&gt;

&lt;p&gt;Choosing an &lt;code&gt;httpOnly&lt;/code&gt; cookie means taking on CSRF, so it's worth knowing what actually defends against it rather than reaching for folklore.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SameSite&lt;/code&gt; cookie attribute is the modern front line. &lt;code&gt;SameSite=Lax&lt;/code&gt;, now the default in current browsers, keeps the cookie off cross-site subresource requests, and off &lt;em&gt;unsafe&lt;/em&gt; top-level navigations — a cross-site form &lt;code&gt;POST&lt;/code&gt; that navigates to your app won't carry it — while still sending it on ordinary top-level GET navigations, so normal links and logins keep working. It's a strong mitigation rather than an unconditional barrier: browsers ship compatibility carve-outs (notably a short "Lax-allowing-unsafe" window in which a freshly-set cookie is still sent on a top-level cross-site &lt;code&gt;POST&lt;/code&gt;), so treat it as raising the bar, not sealing the door. &lt;code&gt;Strict&lt;/code&gt; is tighter, but because it withholds the cookie even when a user arrives through a cross-site link, your app simply looks logged-out on that first navigation until a same-site request re-sends it. &lt;code&gt;None&lt;/code&gt; doesn't so much re-open CSRF as remove the &lt;code&gt;SameSite&lt;/code&gt; layer entirely — it now requires &lt;code&gt;Secure&lt;/code&gt;, and you're back to relying on server-side CSRF defenses.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SameSite&lt;/code&gt; is necessary but not a complete answer. It offers no protection against a &lt;em&gt;same-site&lt;/em&gt; attacker — a compromised or attacker-controlled subdomain is same-site to your cookie — it has historical edge cases, and older clients don't honor it. So defense in depth still applies: a synchronizer or double-submit CSRF token, validating the &lt;code&gt;Origin&lt;/code&gt;/&lt;code&gt;Referer&lt;/code&gt; header on state-changing requests, or requiring a custom header that a cross-site form is unable to set (browsers won't let a simple cross-origin form add arbitrary headers). Worth naming for contrast: the localStorage-plus-&lt;code&gt;Authorization: Bearer&lt;/code&gt; approach is inherently CSRF-immune precisely because the browser never attaches that header on its own. That immunity is the one real security advantage of the localStorage pattern — paid for, as we've seen, with its XSS exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two common patterns
&lt;/h2&gt;

&lt;p&gt;Two patterns have emerged as the pragmatic answers, and they're worth understanding as a pair.&lt;/p&gt;

&lt;p&gt;The first is a &lt;strong&gt;short-lived access token in memory, paired with an &lt;code&gt;httpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite&lt;/code&gt; refresh cookie.&lt;/strong&gt; The access token lives in a JavaScript variable — not persisted, so nothing survives a reload to be stolen later — and it's sent as a Bearer header, so it's CSRF-immune. Crucially, it's short-lived. The refresh token, the long-lived credential you actually need to protect, lives in an &lt;code&gt;httpOnly&lt;/code&gt; cookie scoped to the refresh endpoint, where JavaScript can't read it. On load, and whenever the access token expires, the app calls the refresh endpoint to get a new one. This deliberately puts the &lt;em&gt;long-lived&lt;/em&gt; credential where JavaScript can't read it and keeps the &lt;em&gt;reachable&lt;/em&gt; credential short-lived, so what an attacker can quietly exfiltrate and replay offline is only ever a token with minutes left on it. Be clear about what that does and doesn't buy, though: an XSS running in your origin can still call the refresh endpoint itself, read the new access token out of the response, and keep the session alive — and it can call your business APIs directly as the user in the meantime. Short lifetimes shrink the value of a &lt;em&gt;single stolen token replayed elsewhere&lt;/em&gt;; they do not shrink a live same-origin XSS down to a few minutes. The pattern's real win is keeping the refresh token out of JavaScript's reach, not making XSS harmless. It leans entirely on the lifecycle machinery — short lifetimes and rotating refresh tokens — that the final article covers.&lt;/p&gt;

&lt;p&gt;The second — stronger specifically against bearer-token exfiltration and offline replay — is the &lt;strong&gt;Backend-for-Frontend (BFF)&lt;/strong&gt; pattern, in which the browser never holds the JWT at all. The SPA talks only to its own backend, authenticated by an ordinary &lt;code&gt;httpOnly&lt;/code&gt; session cookie; that backend holds the tokens and calls the downstream APIs on the user's behalf. This is what the current OAuth guidance for browser-based apps recommends for higher-security systems, for exactly the reason this article has been building toward: it removes the bearer token from JavaScript's reach entirely, so there's nothing in the page to exfiltrate or replay elsewhere. It still isn't an XSS cure — an attacker running in your origin can drive authenticated actions through the BFF using the same &lt;code&gt;httpOnly&lt;/code&gt; session cookie the browser attaches for legitimate code. What BFF eliminates is token theft and offline replay, not same-origin abuse. The cost is architectural — you need that backend component, and it reintroduces some server-side session state (and its own CSRF handling for that cookie) — but it sidesteps the storage debate rather than trying to win it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reminder from the first article, and the tool to make it concrete
&lt;/h2&gt;

&lt;p&gt;It's easy to think of localStorage as a hiding place. It isn't. A signed JWT — a JWS, the kind this series and the inspector deal with — has a Base64URL-encoded payload that is readable, not encrypted, as the &lt;a href="https://www.devkitlab.com/en/blog/why-is-my-jwt-invalid/" rel="noopener noreferrer"&gt;first article&lt;/a&gt; laid out. (An encrypted JWE is the exception, and a rare one in browser auth.) So the token sitting in localStorage is a plainly readable string that decodes to plainly readable claims. Paste one into the &lt;a href="https://www.devkitlab.com/en/tools/jwt-inspector/" rel="noopener noreferrer"&gt;JWT inspector&lt;/a&gt; and you're looking at exactly what an XSS payload sees the instant it reads &lt;code&gt;localStorage&lt;/code&gt;: the whole token, and every claim inside it, in the clear. (If you want to pull apart a single segment by hand, the &lt;a href="https://www.devkitlab.com/en/tools/base64-encode-decode/" rel="noopener noreferrer"&gt;Base64 encoder/decoder&lt;/a&gt; shows the same thing at a lower level.) That's also the reason you never put anything sensitive in the payload — storage doesn't conceal it and neither does the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three myths worth retiring
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"localStorage is fine as long as I sanitize my inputs."&lt;/strong&gt; Sanitization reduces XSS; it rarely eliminates it, and it only takes one missed sink or one compromised dependency. Betting the entire, replayable token on your app having zero XSS forever is a bet worth not making — which is the whole argument for keeping the long-lived credential out of JavaScript's reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"&lt;code&gt;httpOnly&lt;/code&gt; cookies make me XSS-safe."&lt;/strong&gt; They stop the token from being read and exfiltrated. They don't stop an XSS from making authenticated requests from within your page while the user is logged in. &lt;code&gt;httpOnly&lt;/code&gt; limits damage; it doesn't prevent it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Just put the JWT in a cookie."&lt;/strong&gt; If you're going to use a stateful &lt;code&gt;httpOnly&lt;/code&gt; cookie anyway, it's worth asking whether you need a JWT at all. A self-contained token earns its keep when verification must be stateless and distributed across services; for a single first-party web app talking to its own backend, a plain opaque session cookie backed by server-side session state is often simpler and — as the next article will make vivid — far easier to revoke. Choosing a JWT should be a decision about your verification architecture, not a reflex.&lt;/p&gt;

&lt;p&gt;Two quick notes on scope. This debate is specific to browsers: native mobile apps should use the platform's secure storage (iOS Keychain, Android Keystore) rather than anything resembling localStorage, and desktop or Electron apps carry their own caveats. And nothing here replaces transport security — all of it assumes HTTPS everywhere, without which the storage question is moot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;The arrangement that keeps coming out on top — a short-lived access token paired with a well-guarded refresh token, or a BFF that keeps the token off the browser entirely — reduces how easily a token is stolen. But "reduces" is the honest word: given a real XSS, some exposure remains, and tokens leak through other channels too. Storage decides how easily a token is stolen; it can't decide how much a stolen one is worth once it's out. That second question — keeping the useful life of any single token short, rotating credentials so theft becomes detectable, and being able to shut a compromised session down — is the token lifecycle, and it's the subject of the &lt;a href="https://www.devkitlab.com/en/blog/refresh-tokens-and-revoking-jwts/" rel="noopener noreferrer"&gt;final article&lt;/a&gt;: short lifetimes, refresh-token rotation, and revocation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.devkitlab.com/en/blog/where-to-store-a-jwt/" rel="noopener noreferrer"&gt;DevKitLab&lt;/a&gt;, which I build and maintain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>jwt</category>
    </item>
    <item>
      <title>Building a Local-First Toolbox for Everyday Digital Work</title>
      <dc:creator>devkitlab</dc:creator>
      <pubDate>Wed, 10 Jun 2026 09:03:05 +0000</pubDate>
      <link>https://dev.to/devkitlab/building-a-local-first-toolbox-for-everyday-digital-work-nhf</link>
      <guid>https://dev.to/devkitlab/building-a-local-first-toolbox-for-everyday-digital-work-nhf</guid>
      <description>&lt;p&gt;I’ve been working on a small tools website called &lt;strong&gt;DevKitLab&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It’s not a complicated idea. Most of the tools are things people already know: JSON formatting, JSON diff, regex testing, cron expressions, QR code generation, even Morse code.&lt;/p&gt;

&lt;p&gt;None of these sound new.&lt;/p&gt;

&lt;p&gt;But that’s kind of the point.&lt;/p&gt;

&lt;p&gt;A lot of online tools are “good enough” only when the input is perfect. They work for clean examples, small snippets, and simple cases. But in real work, the data is usually not that clean.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It may come from logs. &lt;/li&gt;
&lt;li&gt;It may come from an API response. &lt;/li&gt;
&lt;li&gt;It may be copied from someone else’s system. &lt;/li&gt;
&lt;li&gt;It may be too large for a basic textarea. &lt;/li&gt;
&lt;li&gt;It may be valid, but still hard to read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I didn’t want DevKitLab to be just another long list of small utilities.&lt;/p&gt;

&lt;p&gt;I wanted to take some common tools and make them feel a little more considered.&lt;/p&gt;




&lt;h2&gt;
  
  
  JSON Formatter: not just pretty-printing
&lt;/h2&gt;

&lt;p&gt;JSON Formatter is one of the tools I cared about the most.&lt;/p&gt;

&lt;p&gt;I use JSON formatters all the time, but most of them feel the same: paste JSON, click format, get indented output.&lt;/p&gt;

&lt;p&gt;That works when the input is already valid and small.&lt;/p&gt;

&lt;p&gt;But that’s not always what happens.&lt;/p&gt;

&lt;p&gt;Sometimes the JSON is copied from logs and has extra characters around it.&lt;br&gt;&lt;br&gt;
Sometimes it has a trailing comma or a missing quote.&lt;br&gt;&lt;br&gt;
Sometimes a value is actually an escaped JSON string, so the structure is hidden inside another string.&lt;br&gt;&lt;br&gt;
Sometimes the file is a few MB, and the page starts to feel slow.&lt;/p&gt;

&lt;p&gt;For this tool, I didn’t want to build only a textarea with a format button.&lt;/p&gt;

&lt;p&gt;I wanted it to feel more like a lightweight JSON editor.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can try to repair common JSON issues. &lt;/li&gt;
&lt;li&gt;It can expand escaped JSON values so you don’t have to copy them into another tool. &lt;/li&gt;
&lt;li&gt;It supports folding and expanding nodes, so large JSON is easier to browse. &lt;/li&gt;
&lt;li&gt;It is designed to stay usable with JSON that is a few MB in size. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The experience is closer to working in VS Code than using a basic online formatter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn0wj76douwpq34adn7j1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn0wj76douwpq34adn7j1.png" alt="DevKitLab JSON Formatter interface with formatted JSON and collapsible tree view" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This tool shaped a lot of how I think about the rest of the site.&lt;/p&gt;

&lt;p&gt;A small tool should not only work for demo input. It should help with the messy cases people actually run into.&lt;/p&gt;




&lt;h2&gt;
  
  
  JSON Diff: comparing structure, not just text
&lt;/h2&gt;

&lt;p&gt;JSON Diff is another tool I wanted to make more useful than a plain text comparison.&lt;/p&gt;

&lt;p&gt;When I compare JSON, I’m usually not interested in whitespace or formatting changes. I want to know what actually changed in the data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did a field get added? &lt;/li&gt;
&lt;li&gt;Was something removed? &lt;/li&gt;
&lt;li&gt;Did a value change? &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is the difference real, or is it just formatting?&lt;/p&gt;

&lt;p&gt;This happens a lot when comparing API responses, config files, test results, or data from two environments.&lt;/p&gt;

&lt;p&gt;A normal text diff can be noisy for JSON. If the formatting changes, it may look like everything changed, even when the data is mostly the same.&lt;/p&gt;

&lt;p&gt;So the goal of JSON Diff is to make the meaningful changes easier to see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg5uwzt3dhfpc6yqoqiw4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg5uwzt3dhfpc6yqoqiw4.png" alt="DevKitLab JSON Diff comparing two similar JSON objects and highlighting small field-level changes" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For me, this kind of tool is useful when it reduces the amount of guessing. I don’t want to stare at two large blocks of text and mentally filter out the noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Regex Tester: make debugging feel less like guessing
&lt;/h2&gt;

&lt;p&gt;Regex is powerful, but it’s also very easy to get wrong.&lt;/p&gt;

&lt;p&gt;Most regex testers can show whether a pattern matches the text. That is useful, but it is not always enough.&lt;/p&gt;

&lt;p&gt;When I debug a regex, I usually want to understand the pattern piece by piece. If I hover over a part of the expression, I want to see what it means. If something matches, I want to know why. If something does not match, I want to find the exact part that needs to change.&lt;/p&gt;

&lt;p&gt;So I wanted this tool to feel more like a small regex workspace, not just a match checker.&lt;/p&gt;

&lt;p&gt;The flow I had in mind was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write a pattern. &lt;/li&gt;
&lt;li&gt;Hover over parts of the regex to understand what they do. &lt;/li&gt;
&lt;li&gt;Test it against real text. &lt;/li&gt;
&lt;li&gt;See highlighted matches and match details. &lt;/li&gt;
&lt;li&gt;Try replace when the goal is transformation, not just matching. &lt;/li&gt;
&lt;li&gt;Try split when the pattern is used as a separator. &lt;/li&gt;
&lt;li&gt;Use explain to make the expression easier to read. &lt;/li&gt;
&lt;li&gt;Export the pattern as code when it is ready to use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DevKitLab Regex Tester showing a regular expression tested against sample text with highlighted matches&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5uia86hj32b70hxhi1e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5uia86hj32b70hxhi1e.png" alt="DevKitLab Regex Tester showing a regular expression tested against sample text with highlighted matches" width="799" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The point is not to make regex simple. Regex probably won’t ever feel simple.&lt;/p&gt;

&lt;p&gt;But the tool should make the feedback clearer. It should help you move from “I think this pattern works” to “I understand why this pattern works, and I can use it in code.”&lt;/p&gt;




&lt;h2&gt;
  
  
  Cron Expression: turning a short string into real times
&lt;/h2&gt;

&lt;p&gt;Cron expressions are small, but they are not very readable.&lt;/p&gt;

&lt;p&gt;Something like &lt;code&gt;0 9 * * 1-5&lt;/code&gt; is fine if you write cron all the time. But once the expression becomes a little more complex, I often want to double-check it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Will it run when I think it will run? &lt;/li&gt;
&lt;li&gt;What are the next few execution times? &lt;/li&gt;
&lt;li&gt;Did I misunderstand the day-of-week field? &lt;/li&gt;
&lt;li&gt;Is there any timezone-related confusion?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s what I want from a cron tool.&lt;/p&gt;

&lt;p&gt;Not just validation, but a quick way to turn the expression into something human-readable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1j28w5ujwrvxnmjlu8n4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1j28w5ujwrvxnmjlu8n4.png" alt="DevKitLab Cron Expression tool showing a cron schedule with upcoming run times" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is one of those tools that feels small, but can save you from a very annoying mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  QR Code Generator: simple, but still worth polishing
&lt;/h2&gt;

&lt;p&gt;A QR code generator sounds almost too simple.&lt;/p&gt;

&lt;p&gt;You enter some text, and it generates a QR code.&lt;/p&gt;

&lt;p&gt;But even simple tools can be made smoother.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sometimes the input is a URL. &lt;/li&gt;
&lt;li&gt;Sometimes it is plain text. &lt;/li&gt;
&lt;li&gt;Sometimes you want to adjust the size. &lt;/li&gt;
&lt;li&gt;Sometimes you want a quick download. &lt;/li&gt;
&lt;li&gt;Sometimes you just want the page to stay out of your way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frli7qur4a6qo6n4402ku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frli7qur4a6qo6n4402ku.png" alt="DevKitLab QR Code Generator showing a generated QR code with customization and download options" width="799" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For this kind of tool, I don’t think the goal is to add a huge number of features. The goal is to make the common flow fast and predictable.&lt;/p&gt;

&lt;p&gt;Open it. &lt;br&gt;
Generate the QR code. &lt;br&gt;
Download or copy it. &lt;br&gt;
Move on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Morse Code: not every tool has to be serious
&lt;/h2&gt;

&lt;p&gt;Morse Code is a little different from the others.&lt;/p&gt;

&lt;p&gt;It is not something most people use every day. It is not like JSON, regex, or cron.&lt;/p&gt;

&lt;p&gt;But I still wanted to include it because a toolbox does not have to be only about serious work.&lt;/p&gt;

&lt;p&gt;Some tools are for debugging. &lt;br&gt;
Some are for productivity. &lt;br&gt;
Some are just fun to have.&lt;/p&gt;

&lt;p&gt;Morse code sits somewhere in that last category.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe1ybdajyf1mkr93jjq6b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe1ybdajyf1mkr93jjq6b.png" alt="DevKitLab Morse Code tool converting text into Morse code" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It may not be the most practical tool on the site, but I like having a few tools that make the project feel less rigid.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I’m trying to build
&lt;/h2&gt;

&lt;p&gt;The more I work on DevKitLab, the less I care about simply adding more tools.&lt;/p&gt;

&lt;p&gt;More tools are useful, of course. But quantity alone is not very interesting.&lt;/p&gt;

&lt;p&gt;What matters more to me is whether each tool feels like someone actually thought about how it would be used.&lt;/p&gt;

&lt;p&gt;JSON Formatter should be more than pretty-printing.&lt;br&gt;&lt;br&gt;
JSON Diff should make real data changes easier to see.&lt;br&gt;&lt;br&gt;
Regex Tester should make debugging clearer.&lt;br&gt;&lt;br&gt;
Cron Expression should turn abstract syntax into actual times.&lt;br&gt;&lt;br&gt;
QR Code Generator should keep the common flow quick.&lt;br&gt;&lt;br&gt;
Morse Code can just be simple and enjoyable.&lt;/p&gt;

&lt;p&gt;None of these tools are huge products by themselves.&lt;/p&gt;

&lt;p&gt;But if each one removes a little friction, the whole toolbox becomes more useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why local-first matters to me
&lt;/h2&gt;

&lt;p&gt;Another thing I care about is keeping things local whenever possible.&lt;/p&gt;

&lt;p&gt;If you only need to format JSON, that should not require uploading it.&lt;br&gt;&lt;br&gt;
If you are testing regex, the text can stay in the browser.&lt;br&gt;&lt;br&gt;
If you are generating a QR code, the browser can do that.&lt;br&gt;&lt;br&gt;
If a tool can run locally, I’d rather keep it local.&lt;/p&gt;

&lt;p&gt;Not every tool can work that way, and that’s fine.&lt;/p&gt;

&lt;p&gt;But for many everyday utilities, the browser is already enough.&lt;/p&gt;

&lt;p&gt;And in practice, the data people paste into tools is not always meaningless. It can come from work, APIs, configs, logs, files, or private notes. Even when it is not extremely sensitive, people should not have to send it somewhere if there is no reason to.&lt;/p&gt;

&lt;p&gt;So local-first is not just a technical preference for me. It is part of the product experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;DevKitLab is still early.&lt;/p&gt;

&lt;p&gt;Some tools are close to the direction I want. Some are still too basic and need more work.&lt;/p&gt;

&lt;p&gt;I want to improve the examples, make the local-processing behavior clearer, polish the interactions, and connect related tools in a more natural way.&lt;/p&gt;

&lt;p&gt;I don’t want it to become a random collection of utilities.&lt;/p&gt;

&lt;p&gt;I want it to become the kind of site you open when you have a small digital task and just want to get it done without friction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A JSON response to inspect. &lt;/li&gt;
&lt;li&gt;Two JSON objects to compare. &lt;/li&gt;
&lt;li&gt;A regex to debug. &lt;/li&gt;
&lt;li&gt;A cron expression to verify. &lt;/li&gt;
&lt;li&gt;A QR code to generate. &lt;/li&gt;
&lt;li&gt;A Morse message to play with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small things, but they come up all the time.&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://www.devkitlab.com/" rel="noopener noreferrer"&gt;DevKitLab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you use small online tools often, I’d love to know:&lt;/p&gt;

&lt;p&gt;What’s a tool you use regularly, but still feel could be much better?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
  </channel>
</rss>
