Stop Writing Regex to Match URLs โ€” The Browser Already Can
DEV Community

Stop Writing Regex to Match URLs - The Browser Already Can

Priya was three paragraphs into rewriting a support ticket when the page flashed and her draft reverted to what it had looked like an hour earlier. She hadn't refreshed. Nobody had. The service worker had. It was running a cache-first strategy for ticket pages - fetch once, serve from cache after that, so the dashboard felt instant on a flaky connection. The intent was to cache /tickets/482 , the read-only view, and leave /tickets/482/edit alone, since an edit form is exactly the page you never want served stale. Here's the line that decided which was which: const isTicketView = /^/tickets/\d+/.test(pathname); Spot it yet? Read it once more before you scroll. The missing character was $ /^/tickets/\d+/ anchors the start of the string - ^ - but never anchors the end. So it matches /tickets/482 . It also matches /tickets/482/edit , /tickets/482/history , and /tickets/482-anything-at-all , because "one or more digits after /tickets/ " is true of all of them. The regex was never wrong about what it checked. It just never checked enough. The one-character fix is obvious once you see it: const isTicketView = /^/tickets/\d+$/.test(pathname); Ship that and you'll hit the next edge case within a week: a trailing slash (/tickets/482/ ) now fails to match, because $ demands nothing comes after the digits - not even a slash. Add /? before the $ and you've fixed that one. Then someone deep-links to /tickets/482?tab=history and the query string breaks the anchor again, because pathname on some code paths actually holds the full URL. Each fix is a patch on the last, and every patch is a chance to reintroduce the first bug in a new shape. This is the part nobody tells you about hand-rolled URL matching: it isn't hard because regex is hard. It's hard because "does this path match this shape" has a dozen boundary conditions, and a hand-written pattern only encodes the ones you happened to think of on the day you wrote it. The API built for exactly this job The browser has had a purpose-built answer since 2021, and it isn't a regex - it's URLPattern , a global constructor that matches and parses URLs the way a router does, natively: const ticketView = new URLPattern({ pathname: "/tickets/:id" }); ticketView.test({ pathname: "/tickets/482" }); // true ticketView.test({ pathname: "/tickets/482/edit" }); // false No ^ , no $ , no \d+ . /tickets/:id matches the whole pathname component by default - that's not a lucky accident of this example, it's the core design decision. A URLPattern pattern is exact-match unless you explicitly tell it otherwise. There's no anchor to forget, because there's nothing unanchored to begin with. That was invented for this exact scenario, by the way - it originally shipped to give service workers a real routing syntax for fetch event handlers instead of everyone writing their own regex. You're not reaching for an obscure API here; you're reaching for the one built by people who'd already hit Priya's bug. Reading the pattern syntax :id is a named group - it captures a path segment and gives it a name you can read back later. That's the syntax doing double duty: matching and extracting, in one string. Three more pieces cover almost everything else you'll need: // Wildcard - matches anything, including further slashes new URLPattern({ pathname: "/files/*" }).test({ pathname: "/files/2026/q3/report.pdf" }); // true // Optional group - the {โ€ฆ} makes a whole segment, slash included, optional new URLPattern({ pathname: "/blog{/:year}?" }).test({ pathname: "/blog" }); // true new URLPattern({ pathname: "/blog{/:year}?" }).test({ pathname: "/blog/2026" }); // true // Custom regex inside a named group - for when a plain segment isn't specific enough new URLPattern({ pathname: "/:kind(ticket|comment)/:id" }).test({ pathname: "/comment/482" }); // true Four building blocks - a literal, :name , * , and {โ€ฆ}? - cover the shapes that used to take a paragraph of regex to express, and read back close to plain English. Pulling the params back out test() only answers yes or no. When you need the actual id , call exec() instead: const pattern = new URLPattern({ pathname: "/tickets/:id" }); const match = pattern.exec({ pathname: "/tickets/482" }); match.pathname.groups.id; // "482" exec() returns null on no match, and otherwise an object with one entry per URL component you matched against (pathname , search , hash , and so on), each carrying a groups object keyed by name. No manual .split("/") , no match[1] where you have to remember what group 1 was. ๐ŸŽฎ Try it yourself โ–ถ๏ธ Open the interactive playground โ†’ Runs right in your browser - poke at it and watch the concept react live. The fix, with two patterns instead of one regex Back in the service worker, the honest fix isn't a smarter regex - it's naming the two things that were always different: const ticketView = new URLPattern({ pathname: "/tickets/:id" }); const ticketEdit = new URLPattern({ pathname: "/tickets/:id/edit" }); self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); if (ticketView.test(url) && !ticketEdit.test(url)) { event.respondWith(cacheFirst(event.request)); } // ticketEdit, and everything else, falls through to the network. }); ticketView never matches /tickets/482/edit - not because of a character you remembered to add, but because the pattern simply describes a shorter path than the one it's being compared against. There's no anchor to audit in a future code review, because exactness was never optional. Where it doesn't replace anything URLPattern matches and extracts. It doesn't dispatch, and it doesn't rank overlapping routes by specificity the way a full router does - if you hand it a list of ten patterns, you're still the one deciding which to check first. For a page with dozens of nested, code-split routes, you'll still likely reach for a router library that happens to use something like this under the hood. But for the far more common case - a service worker's allowlist, an analytics filter deciding which URLs to sample, a tiny client-side router that only ever needed four routes - you don't need the library. You need the four lines above. It's also worth checking your target audience before you lean on it fully: it's been in Chrome and Edge since 2021, and Firefox and Safari caught up through 2025, so by now it's safe to reach for directly rather than through a polyfill in almost any modern app. The one-line version The regex wasn't wrong about what it checked for - it just never had to promise it checked everything, and one missing $ let an edit form get served like a snapshot. URLPattern doesn't make that promise implicit. Exactness is the default, not a character you have to remember. Next time you catch yourself writing /^/something/\d+/ and mentally listing the edge cases you'll patch in later - trailing slash, query string, that one route with two segments - stop and reach for new URLPattern({ pathname: "..." }) instead. What's the last regex you wrote to match a URL? I'd bet it's missing an anchor somewhere too. ๐Ÿง  Test yourself Think it clicked? Take the 7-question quiz โ†’ Instant feedback, a hint on every question, and an explanation for each answer - right or wrong. Thanks for reading! Let's stay connected: - โญ GitHub - follow me and star the projects: github.com/parsajiravand - ๐Ÿ’ฌ Discord - join the frontend best-practices community: discord.gg/d9KRhuAwQ - ๐Ÿ“ธ Instagram - frontend best practices, daily: @bestpractice___ - ๐Ÿ’ผ LinkedIn - linkedin.com/in/parsa-jiravand - โœ‰๏ธ Email (work & contract inquiries): bes*************@gmail.com Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.