tsx

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

12posts · 30d
Nov 40 posts
  1. Build a BPE Tokenizer in 80 Lines of TypeScript and Watch JSON Eat Your Tokens - Book: AI That Answers - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- The maintainers of the TOON format ran three flat datasets through OpenAI's o200k_base encoding and counted 164,643 tokens for the pretty-printed JSON and 104,908 for the same data minified. Nothing was dropped. The minified version holds every key and every value. It stops paying for indentation and line breaks, and that alone takes about 36% off the count. Tokens are the unit your LLM bill is written in. Anthropic's pricing table, for one, quotes input and output separately, per million tokens. Yet most developers who pay that bill have never watched a string turn into tokens. You can build the machine that does it in 80 lines of TypeScript and train it on your own JSON. What happens to your prompt before the model sees it OpenAI's tokenizers use byte-pair encoding. BPE began as a compression algorithm, and Sennrich, Haddow and Birch adapted it to split words into subword units for neural machine translation (ACL 2016). The tiktoken README says the encoding is reversible and lossless and works on any text, even text the tokenizer never saw in training. On average, a token covers about 4 bytes. Encoding runs in three steps. 1. A regex cuts the text into chunks, so letters, digits, punctuation and whitespace end up in separate runs. Karpathy's minbpe credits this to the GPT-2 paper and notes it "ensures that no merges will happen across category boundaries." 2. Each chunk becomes its UTF-8 bytes. The starting vocabulary is the 256 possible byte values, so nothing is ever out of vocabulary. 3. Learned merges glue adjacent byte pairs into bigger tokens, in the order they were learned. Training is where the merges come from. Count every adjacent pair across the corpus, take the pair with the highest count, give it the next free id (256, then 257, and so on), replace it everywhere, repeat. The minbpe README walks through aaabdaaabac: three merges (aa, then ab, then the pair of those two new tokens) turn 11 bytes into five tokens, [258, 100, 258, 97, 99]. !Rows of small square byte tiles fusing pairwise into larger tiles, layer by layer Byte-pair encoding: start from single bytes, then fuse the most frequent neighbours, one merge at a time. The tokenizer in 80 lines This is the whole trainer and encoder. It uses GPT-2's pre-split pattern, minus the contraction rules ('s, 'll, and friends), copied from minbpe's regex.py. // bpe.ts: a toy byte-level BPE trainer and encoder type Pair = [number, number]; // GPT-2's pre-split pattern, minus its contraction rules const SPLIT = / ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu; const utf8 = new TextEncoder(); const text8 = new TextDecoder(); const key = (a: number, b: number) => `${a},${b}`; const show = (b: Uint8Array) => JSON.stringify(text8.decode(b)); const chunksOf = (text: string) => (text.match(SPLIT) ?? []).map((c) => Array.from(utf8.encode(c))); function countPairs(ids: number[], counts: Map<string, number>) { for (let i = 0; i < ids.length - 1; i++) { const k = key(ids[i], ids[i + 1]); counts.set(k, (counts.get(k) ?? 0) + 1); } } function merge(ids: number[], [a, b]: Pair, id: number) { const out: number[] = []; for (let i = 0; i < ids.length; i++) { if (ids[i] === a && ids[i + 1] === b) { out.push(id); i++; // the pair's second half is consumed too } else out.push(ids[i]); } return out; } export function train(text: string, numMerges: number) { let chunks = chunksOf(text); const merges = new Map<string, number>(); const vocab = new Map<number, Uint8Array>(); for (let b = 0; b < 256; b++) vocab.set(b, Uint8Array.of(b)); for (let m = 0; m < numMerges; m++) { const counts = new Map<string, number>(); for (const ids of chunks) countPairs(ids, counts); let best = ""; let bestCount = 1; // a pair seen once earns no token for (const [k, c] of counts) { if (c > bestCount) { best = k; bestCount = c; } } if (!best) break; const [a, b] = best.split(",").map(Number) as Pair; const id = 256 + m; chunks = chunks.map((ids) => merge(ids, [a, b], id)); merges.set(best, id); const l = vocab.get(a)!, r = vocab.get(b)!; const bytes = new Uint8Array([...l, ...r]); vocab.set(id, bytes); console.log(`#${m}`, show(l), "+", show(r), "->", show(bytes), `${bestCount}x`); } return { merges, vocab }; } export function encode(text: string, merges: Map<string, number>) { const out: number[] = []; for (let ids of chunksOf(text)) { while (ids.length > 1) { // earliest-learned merge first, the order training used let pair: Pair | null = null; let rank = Infinity; for (let i = 0; i < ids.length - 1; i++) { const r = merges.get(key(ids[i], ids[i + 1])); if (r !== undefined && r < rank) { rank = r; pair = [ids[i], ids[i + 1]]; } } if (!pair) break; ids = merge(ids, pair, rank); } out.push(...ids); } return out; } Pairs are keyed as "a,b" strings because a Map compares array keys by reference, so [1, 2] would never find [1, 2]. Ties go to the pair seen first, since Map iterates in insertion order and the comparison is strict. Training works chunk by chunk, so a merge can never span a letter and a digit, or a word and the punctuation after it, which matches the real thing. And encode always applies the lowest-ranked merge available, the same rule minbpe's _encode_chunk uses: replay the merges in the order training learned them. The show helper decodes a token's bytes for printing. When a token holds only part of a multi-byte character, TextDecoder prints �, because the token boundary falls inside the character. The Japanese sample further down does the same. Train it on JSON and read the merges Feed it the kind of text your app actually sends: an array of records, pretty-printed. // demo.ts import { train, encode } from "./bpe"; const records = Array.from({ length: 40 }, (_, i) => ({ customer_id: 1000 + i, status: i % 4 ? "active" : "suspended", country: ["DE", "BR", "JP", "US"][i % 4], })); const pretty = JSON.stringify(records, null, 2); const { merges } = train(pretty, 40); const one = records[0]; const asPretty = encode(JSON.stringify(one, null, 2), merges); const asMin = encode(JSON.stringify(one), merges); console.log({ pretty: asPretty.length, minified: asMin.length }); Run it with tsx: npx tsx demo.ts. Each log line is one merge, with the two parts, the new token, and how often the pair appeared. Read all 40 and sort them in your head: how many are fragments of the keys customer_id, status and country, how many are values and digits, and how many are quotes, colons and runs of spaces? On a corpus made of nothing but JSON, every merge has to come from one of those. The keys repeat in every single record, and that repetition is what earns them merges. A production tokenizer learns its merges from a huge general corpus instead, so common English words have a good chance of getting a token of their own (the next script shows you whether customer did). Your acctBalanceAdjFlagV2 pays for every piece. Then try the experiments the script makes cheap. Train on JSON.stringify(records) instead of the pretty version and compare the merge log. Rename customer_id to cid and see how many merges it frees up. Compare the two counts in the last line, and remember that this toy only knows 40 merges, so the ratio between them tells you more than either number. Ask a real encoder: o200k_base Your toy has 40 merges. o200k_base has a vocabulary of about 200,000 tokens, roughly double GPT-4's ~100,000, according to Simon Willison's notes from the GPT-4o launch. tiktoken's model table maps gpt-4o, gpt-4.1, o3, o4-mini and gpt-5 to it. js-tiktoken loads it in Node; the lite import pulls in only the one rank file you need. // compare.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; const enc = new Tiktoken(o200k_base); const pieces = (s: string) => enc.encode(s).map((id) => enc.decode([id])); const samples = [ '{\n "customer_id": 1000\n}', '{"customer_id":1000}', "customerShippingAddressLine2", "1730505600000", "Your order has been cancelled.", "ご注文はキャンセルされました。", ]; for (const s of samples) { const p = pieces(s); console.log(p.length, JSON.stringify(p)); } You can predict part of what it prints by reading the o200k pre-split regex in tiktoken's openai_public.py. Every chunk the regex produces costs at least one token, so the regex sets a floor. - camelCase splits at every capital. The letter rule matches a run of uppercase letters (possibly none) followed by lowercase, so customerShippingAddressLine2 cuts into customer, Shipping, Address, Line and 2 before BPE starts. Five chunks, at least five tokens. - Digits go in groups of three. The number rule is \p{N}{1,3}. A 13-digit millisecond timestamp is five chunks, so at least five tokens, every time it appears in a log line you paste. - Whitespace is its own chunk. Indentation runs, minus their last space, are cut out separately from the keys they sit in front of. One small quirk: the space after "customer_id": in the pretty version cannot attach to a following number, because the digit rule (\p{N}{1,3}) takes no leading character and the punctuation rule's optional space needs a symbol after it. It becomes a chunk on its own. Before a string value it rides along with the opening quote instead. - Non-English text gets fewer merges. Byte-level BPE can encode anything, but the merges go to whatever was common in training data. Petrov et al. (NeurIPS 2023) found the same text translated into different languages could differ in token length by up to 15 times on the tokenizers they studied. OpenAI's larger vocabulary cut non-English counts: its launch figures, as quoted in Willison's notes, put Gujarati at 4.4x fewer tokens and Japanese at 1.4x fewer than the previous tokenizer. When a token boundary still lands inside a Japanese character, pieces prints � for those fragments. !A long accordion of curly braces and indentation blocks next to a compact stack of the same blocks Same data, same keys and values; only the whitespace and repetition change, and that is where the tokens go. One payload, three shapes Now measure the thing you actually send. This script builds one payload and counts it as pretty JSON, minified JSON and TOON, a tabular encoding of the JSON data model that declares the keys of a uniform array once (@toon-format/toon on npm). // shapes.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; import { encode as toToon } from "@toon-format/toon"; const enc = new Tiktoken(o200k_base); const orders = Array.from({ length: 50 }, (_, i) => ({ orderId: 5000 + i, customerId: 70 + (i % 9), status: i % 3 ? "shipped" : "pending", totalCents: 1999 + i * 37, })); const payload = { orders }; const shapes: Record<string, string> = { pretty: JSON.stringify(payload, null, 2), minified: JSON.stringify(payload), toon: toToon(payload), }; for (const [name, text] of Object.entries(shapes)) { console.log(name.padEnd(9), enc.encode(text).length); } Swap payload for a real request body from your logs and you have your own numbers. I can't give you numbers for your data, so here are published ones for theirs. TOON's benchmark, counted with o200k_base: | Track | Pretty JSON | Minified JSON | TOON | |---|---|---|---| | Flat data, 3 datasets | 164,643 | 104,908 | 68,030 | | Mixed structure, 5 datasets | 393,637 | 260,451 | 264,734 | On flat, uniform records TOON came in 58.7% under pretty JSON and 35.2% under minified. On mixed and nested data the picture flips: minified JSON beat TOON by 1.6% overall, and the README says plainly that "for deeply nested or non-uniform data, JSON may be more efficient." It also notes that absolute counts are tokenizer-specific, since other providers tokenize differently, and that you have to measure latency yourself. Fewer tokens is also not the same as better answers, so run your evals on the new shape before you ship it. One win held on both tracks: minifying the JSON cut about a third of the tokens, so do that to anything a program produced before it goes into a prompt. Dropping keys the model does not need helps whatever the shape. Save the tabular format for long arrays of identical objects, where TOON won, and keep JSON for nested data, where it lost. Then put shapes.ts next to your prompt builder and check the count every time someone adds a field. --- If this was useful Token cost is the first bill an LLM app runs up, and it is one of the threads in AI That Answers, the first book of my AI in TypeScript series. It covers your first LLM app in TypeScript end to end: prompts, structured output, and keeping an eye on what each call costs before the invoice does it for you. ![AI That Answers — AI in TypeScript](amazon.com) The other four books go from retrieval and tool calling to agents in production: the whole AI in TypeScript series is here.

    3 reactions · 4 comments
  2. Node Runs .ts Files Natively. Inside node_modules It Refuses, on Purpose - Book: TypeScript in Production - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You stop compiling. Your package's exports points straight at ./src/index.ts, every script in the monorepo runs with plain node, and the tests are green. Then someone installs the package from the registry, runs their app, and Node stops with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. No flag turns it off. The Node.js TypeScript docs give the reason in one sentence: "To discourage package authors from publishing packages written in TypeScript, Node.js refuses to handle TypeScript files inside folders under a node_modules path." The refusal makes sense once you see how little Node does to a .ts file. How Node runs a .ts file without compiling it Type stripping landed in v22.6.0, became the default in v23.6.0 and v22.18.0, and was marked stable in v25.2.0 and v24.12.0. Current releases of every LTS line on the Node releases page (22, 24 and 26, with v26.13.0 the newest LTS release listed there at the time of writing) have it on by default. You opt out with --no-strip-types. The mechanism is deliberately dumb. Node doesn't compile anything. It finds the TypeScript syntax and overwrites it with spaces. This is the example from the node:module docs: import { stripTypeScriptTypes } from 'node:module'; const code = 'const a: number = 1;'; const strippedCode = stripTypeScriptTypes(code); console.log(strippedCode); // Prints: const a = 1; The = 1 is still in the column where you typed it. An interface that spans twelve lines becomes twelve blank lines. Because nothing moves, a stack trace points at the right line of your .ts file, and the docs note that "source maps are unnecessary for correct line numbers in stack traces; and Node.js does not generate them." The parser doing this is Amaro, a wrapper around SWC's TypeScript parser compiled to WebAssembly. Being dumb has consequences, and they're all in the docs. There is no type checking. Node ignores tsconfig.json, so paths aliases don't work. Imports need their real extension (./user.ts, not ./user). Type-only imports must say import type, or Node treats them as values and fails at runtime. .tsx is unsupported. !A line of code with its type annotations lifted out as blocks, leaving empty gaps while every other character stays in its column Type stripping removes the annotations and leaves the gaps, so every line and column stays where it was. The syntax whitespace can't erase If stripping is "replace with spaces", anything that needs Node to write new JavaScript is out. The Node docs list the main cases: enums, namespaces with runtime code, parameter properties and import aliases. Hitting one throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. The reason is visible in what tsc emits for them. An enum is a real object at runtime, so the compiler has to generate the code that builds it. A parameter property is shorthand for an assignment the compiler writes into the constructor. Neither exists in your source as JavaScript, so blanking characters can't produce it. // Rejected by type stripping class User { constructor(public name: string) {} } enum Role { Admin, Viewer } The erasable versions say the same thing in syntax that's already JavaScript once the types are gone: class User { name: string; constructor(name: string) { this.name = name; } } const Role = { Admin: 0, Viewer: 1 } as const; type Role = (typeof Role)[keyof typeof Role]; You don't want to find these one runtime crash at a time. TypeScript 5.8 added erasableSyntaxOnly for exactly this: with it on, tsc errors on enums, namespaces and modules with runtime code, parameter properties, import = and export =. The tsconfig reference adds <number>value-style assertions to the list. The Node docs recommend TypeScript 5.8 or newer with erasableSyntaxOnly, verbatimModuleSyntax and rewriteRelativeImportExtensions turned on. There used to be an escape hatch. --experimental-transform-types arrived in v22.7.0 and was removed in v26.0.0, along with the transform mode of stripTypeScriptTypes. On Node 26 the built-in path is strip-only. For full TypeScript, the docs point you to a third-party runner such as tsx. Reproduce the refusal (your monorepo hides it) Here's a two-package npm workspace where the library ships raw TypeScript: greet-demo/ ├── package.json "workspaces": ["packages/*"] └── packages/ ├── greet-ts/ │ ├── package.json │ └── src/index.ts └── app/ ├── package.json └── main.ts The library's manifest points exports at source: { "name": "greet-ts", "version": "1.0.0", "type": "module", "exports": "./src/index.ts" } // packages/greet-ts/src/index.ts export function greet(name: string): string { return `hello, ${name}`; } // packages/app/main.ts (app has "type": "module") import { greet } from "greet-ts"; console.log(greet("ada")); Run npm install at the root, then node packages/app/main.ts, and it should run. That's because npm symlinks each workspace into node_modules. By default Node dereferences symlinks and uses the module's "real path" as its identity. As Jake Bailey points out in nodejs/typescript#14, the "is this in node_modules" check looks at that real path. So Node sees packages/greet-ts/src/index.ts, with no node_modules anywhere in it, and strips it happily. Your consumers don't get a symlink. They get a copy. Pack the library the way the registry would and install the tarball somewhere else: cd ~/code/greet-demo/packages/greet-ts npm pack mkdir ~/code/consumer && cd ~/code/consumer npm init -y npm install ~/code/greet-demo/packages/greet-ts/greet-ts-1.0.0.tgz Copy main.ts over as main.mts (.mts always runs as ESM, whatever npm init wrote into type) and run node main.mts. The library's index.ts now lives at node_modules/greet-ts/src/index.ts. That is the case the errors reference describes for ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING: "Type stripping is not supported for files descendant of a node_modules directory." I haven't run this exact repro myself; the error code and its trigger come straight from those docs. npm's install-links setting, which packs file: dependencies (not workspaces) instead of symlinking them, is another way to get the same copy-not-link setup in CI. !A stack of shipping crates stopped at a closed barrier while a single tethered crate on a side path passes through freely A symlinked workspace package resolves outside node_modules and runs. The same files installed from a tarball sit inside node_modules and are refused. Why Node says no The request to lift the restriction is nodejs/typescript#14, and it was closed as not planned. The issue's author makes a fair case: Bun, Deno and JSR already run or host TypeScript source, so Node holding out alone won't stop the trend. Replies in the thread explain the other side. Matteo Collina wrote that "TypeScript is designed so that only .d.ts files are shared across projects." Jake Bailey raised the problem that a published .ts file gets type-checked under the consumer's tsconfig, not the author's. Jordan Harband added that, as he understood it, the TypeScript team opposes the idea too, because it would mean they could never make breaking changes to TypeScript's syntax again. TypeScript's own compiler options guide shows the type-checking cost. A separate outDir is "necessary for libraries that publish their input files. Otherwise, extension substitution will cause the library's consumers to load the library's .ts files instead of .d.ts files, causing type errors and performance problems." Raw .ts in node_modules hands the consumer's compiler your source and your strictness assumptions. If you control both ends, there is a workaround. One commenter in the thread shares a loader built on module.registerHooks() and stripTypeScriptTypes(), both "release candidate" stability. That loader calls stripTypeScriptTypes() in transform mode, which Node 26 removed, so on current Node the same idea would have to use the default strip mode and handle erasable syntax only. That fits an internal app. It isn't something to ask the users of a public package to install. What your package still ships Your own scripts and tests can run the .ts files directly. The thing you publish is still compiled JavaScript plus declarations. One tsconfig covers both, combining the Node recommendations with the library settings from the TypeScript guide: { "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "rewriteRelativeImportExtensions": true, "declaration": true, "rootDir": "src", "outDir": "dist" } } rewriteRelativeImportExtensions (TypeScript 5.7) is what lets one source tree serve both: you write import { x } from "./x.ts", Node runs it as-is, and tsc rewrites it to ./x.js in dist. Then the manifest points at the build output: { "name": "greet-ts", "version": "1.0.0", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "files": ["dist"], "scripts": { "build": "tsc -p tsconfig.json" } } Key order matters in conditional exports: "earlier entries have higher priority," and the types condition "should always be included first." Node doesn't type-check, so keep a tsc --noEmit step in CI. JSR: the registry that compiles for you JSR is the one registry where publishing TypeScript source is the expected path. It "supports and encourages publishing TypeScript source code rather than pairs of .js + .d.ts files." The catch is what reaches Node. Through JSR's npm compatibility layer, "this source code is transpiled to JavaScript," and .d.ts files "are generated for all TypeScript files." A Node user who runs npx jsr add, or installs with pnpm 10.9+ or Yarn 4.9+, gets JavaScript in node_modules. That's also why JSR enforces its "slow types" rule on exported functions, classes and variables: explicit types are what let it generate declarations for the npm layer. --- If this was useful Getting a package from "it runs in my repo" to "it runs in yours" is most of what TypeScript in Production covers: tsconfig choices, build tools, monorepos, exports maps, dual ESM/CJS and publishing to JSR. If your team has started running .ts directly and is wondering what still needs a build, that book is the one to grab. It's the fifth book in The TypeScript Library: - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR Books 1 and 2 are the core path. If you're coming from the JVM or PHP, swap in book 3 or 4 for those. Book 5 is for anyone shipping TypeScript at work, which, if you read this far, is probably you. All five books ship in ebook, paperback, and hardcover. ![The TypeScript Library — the 5-book collection](xgabriel.com)

    1 reaction
  3. LLM API Slows to 30s, Your Server Holds 600 Requests: Little's Law and a Load Shedder - Book: System Design Pocket Guide: Fundamentals - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Your LLM provider is up. Every call just takes 30 seconds instead of one. Nothing errors. Your health check is green. And your Node process is slowly filling up with requests that are all waiting on the same slow upstream. The loud failures make the status pages. DeepSeek logged an incident at 8:11 Beijing time on October 15, and chat and API users got "the server was temporarily unavailable" until a fix moved to monitoring at 12:49, per TechNode. Five days later OpenAI posted authentication errors for some users near Illinois, API included, for an hour, fixed by rolling back a configuration change. The slow afternoon usually never gets an entry. You can predict exactly how full your process gets with one line of arithmetic, and you can stop it with a counter and a fast 503, checked against a simulation you can run. The arithmetic: L = λW Little's Law, proved by John Little in 1961, says that in a stable system the average number of items inside (L) equals the arrival rate (λ) times the average time each one spends inside (W). It does not care about the arrival distribution or the order things are served in. That is why it is so useful for back-of-the-envelope capacity work. Apply it to an endpoint that proxies an LLM call: - Normal day: 20 requests per second, 1 second per call. L = 20 × 1 = 20 requests in flight. - Upstream slows down: same 20 requests per second, 30 seconds per call. L = 20 × 30 = 600 requests in flight. Your traffic did not change. Your code did not change. Your concurrency went up 30x because W did. Each of those 600 requests holds a socket to the client, a socket to the provider, the prompt, the buffered response, and a promise chain in memory. Users rarely wait 30 seconds either. Some hit refresh, which raises λ while W stays high. Netflix's concurrency-limits library builds on the same identity: limit = average RPS × average latency. Flip it around and you get the two levers you have. You can cap W (timeouts) or you can cap L directly (a concurrency limit). λ belongs to your users. !A pipe of identical request tokens backing up behind a narrow, slow valve at the far end When W goes from 1 s to 30 s, the pipe holds 30 times as much, with no extra traffic. A simulation you can run Real load tests against a slow LLM are expensive and hard to reproduce. A deterministic, tick-based simulation is neither. It steps time forward 100 ms at a time, sends 20 requests per second, and makes the upstream take 30 seconds per call between second 30 and second 90. It also needs a way to die. Here the server has a 400 MB budget and each in-flight request holds 1 MB. Both are assumptions, picked to make the failure visible; measure your own. When the budget is exceeded, the process crashes, loses everything it was holding, and takes 10 seconds to come back. Save the file as sim.ts and run it with tsx; there are no dependencies. // sim.ts: run with `npx tsx sim.ts` const TICK = 100; // ms per simulation step const END = 180_000; // simulate three minutes const RPS = 20; // requests arriving per second const PATIENCE = 15_000; // a user gives up after 15 s const TARGET = 2_000; // "healthy" upstream latency const HEAP_MB = 400; // memory budget (assumption) const MB_PER_REQ = 1; // held per in-flight request const RESTART = 10_000; // crash-to-ready time // Fast, then 30 s per call for one minute, then fast. const upstream = (t: number) => t >= 30_000 && t < 90_000 ? 30_000 : 1_000; type Policy = { name: string; timeout: number; // Infinity = wait forever limit: () => number; // Infinity = no cap onSlow?: () => void; // a call passed TARGET onFast?: (inFlight: number) => void; }; type Req = { start: number; end: number; slow: boolean }; Each tick of run() does three things: settle what is in flight, admit new arrivals, and check the memory budget. Append it to the same sim.ts. function run(p: Policy) { let inFlight: Req[] = []; let downUntil = 0; let due = 0; // fractional arrivals carried between ticks const s = { ok: 0, late: 0, timedOut: 0, shed: 0, lost: 0, crashes: 0, peak: 0 }; for (let t = 0; t < END; t += TICK) { // finish, time out, or flag what is in flight const still: Req[] = []; for (const r of inFlight) { if (r.end <= t) { const took = r.end - r.start; if (took <= PATIENCE) s.ok++; else s.late++; if (took <= TARGET) p.onFast?.(inFlight.length); } else if (t - r.start >= p.timeout) { s.timedOut++; } else { if (!r.slow && t - r.start > TARGET) { r.slow = true; p.onSlow?.(); } still.push(r); } } inFlight = still; // new arrivals this tick (exact for any RPS) due += (RPS * TICK) / 1000; for (; due >= 1; due--) { if (t < downUntil) { s.lost++; continue; } if (inFlight.length >= p.limit()) { s.shed++; continue; } inFlight.push({ start: t, end: t + upstream(t), slow: false }); } s.peak = Math.max(s.peak, inFlight.length); // out of memory: the process dies with its requests if (inFlight.length * MB_PER_REQ > HEAP_MB) { s.crashes++; s.lost += inFlight.length; inFlight = []; downUntil = t + RESTART; } } return s; } The counters mean: ok answered within the user's patience, late answered after the user left (you paid for those tokens and nobody read them), timedOut your server gave up on, shed rejected instantly, lost dropped by a crash or refused while restarting. Three policies that fail differently Start with what a naive proxy does: no limit, no timeout. Then add a timeout, then a fixed limit. const unbounded: Policy = { name: "unbounded", timeout: Infinity, limit: () => Infinity, }; const timeoutOnly: Policy = { name: "timeout only", timeout: PATIENCE, limit: () => Infinity, }; const fixed50: Policy = { name: "limit 50 + timeout", timeout: PATIENCE, limit: () => 50, }; You can predict the unbounded run before you execute it. Little's Law says the in-flight count heads toward 600, but the budget is 400, so the process dies about 20 seconds into the slowdown (20 per second × 20 seconds = 400). It restarts empty, refills at the same rate, and runs into the same wall. Every crash throws away hundreds of requests that had each been waiting for many seconds. The timeout-only run caps W at 15 seconds, so L tops out near 20 × 15 = 300, under the budget. No crash. That is a real improvement, and it shows that a timeout is the cheapest fix there is. The catch is in timedOut: during the slow minute, every single user waits 15 seconds to get an error. And the cap only holds while λ holds. Set RPS = 40, the refresh-button scenario, and 40 × 15 = 600 puts you back over the budget. The fixed limit caps L at 50 no matter what λ does. Most users in the slow minute land in shed: they get a 503 right away instead of a 15-second spinner. The AWS Builders' Library puts the reason plainly: "Early rejection is important because it's the cheapest place to drop excess traffic, but it comes at a cost to visibility." That second half is why you count and graph every shed request. The weakness is the number 50. You picked it for one latency, and the upstream decides what latency you get. !A gatekeeper valve letting a few request tokens through to a slow upstream and bouncing the rest straight back A concurrency limit turns a slow upstream into fast rejections instead of a growing queue. Shedding in a real Node server Here is the same policy in an HTTP handler. The rejection carries Retry-After, which on a 503 tells the client how long the service is expected to be unavailable, as a number of seconds or a date. import { createServer } from "node:http"; const LIMIT = 50; const TIMEOUT_MS = 15_000; let inFlight = 0; createServer(async (req, res) => { if (inFlight >= LIMIT) { res.writeHead(503, { "Retry-After": "5" }); res.end("busy, retry shortly\n"); return; } inFlight++; try { const up = await fetch(process.env.UPSTREAM_URL!, { signal: AbortSignal.timeout(TIMEOUT_MS), }); res.writeHead(up.status); res.end(await up.text()); } catch { res.writeHead(504); res.end("upstream failed or timed out\n"); } finally { inFlight--; } }).listen(3000); The check runs before any work: no body parsing, no auth lookup, no upstream socket. The finally is the part people get wrong; if the counter does not come down on every path, the limit slowly leaks until the server sheds everything. On the client side, honour the header and add jitter, or 50 rejected clients all retry in the same second. Google's SRE book describes the client half too: a per-client retry budget where a request is retried only while retries stay below 10% of traffic. AIMD: let the upstream pick the limit Additive increase, multiplicative decrease is the feedback rule from TCP congestion control: grow slowly while things are fine, cut sharply on a sign of congestion. Netflix's AIMDLimit does this for concurrency: multiply the limit by 0.9 on a drop or a call slower than its timeout, add 1 on success, but only grow when at least half the current limit is in use. function aimd(min = 5, max = 200): Policy { let limit = 20; return { name: "aimd + timeout", timeout: PATIENCE, limit: () => Math.floor(limit), onSlow: () => { limit = Math.max(min, limit * 0.9); }, onFast: (inFlight) => { if (inFlight * 2 >= limit) { limit = Math.min(max, limit + 1); } }, }; } const policies = [unbounded, timeoutOnly, fixed50, aimd()]; console.table( policies.map((p) => ({ policy: p.name, ...run(p) })), ); The main change from Netflix's version: the sim flags a call as slow the moment it passes TARGET, not when it finally returns. With a 30-second upstream, a completion-based signal arrives 30 seconds after the trouble started. Two seconds in is when you want to know. Run all four and compare the shed and timedOut columns for the last two rows. During the slow minute the AIMD limit drops toward its floor of 5, so far fewer users sit through a 15-second wait for an error, and the in-flight count stays tiny. When the upstream recovers, fast completions push the limit back up by one each, and the "half in use" guard stops it from inflating while traffic is light. TARGET is the knob that carries your judgement. At 2 seconds, an upstream that settles at 10 seconds would also be treated as broken, even though a patient user could still be served. Set it from your own latency data, not from this post. What to take to your service Do the arithmetic for your worst endpoint first: peak RPS times the worst latency your provider has shown you. If that number of in-flight requests would hurt, you need a timeout today and a concurrency limit that answers with a fast 503 and Retry-After. AIMD is the next step once you are tired of guessing the right number. Then change upstream, RPS and HEAP_MB in the sim to match your own service and see which row you are living in. --- If this was useful Little's Law, timeouts, load shedding and backpressure are the parts of system design that only feel abstract until a provider has a slow afternoon. The System Design Pocket Guide: Fundamentals covers those building blocks, with the capacity arithmetic that tells you where your service breaks before your users find it. ![System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems](amazon.com)

    1 reaction
  4. Postgres NOTIFY Is a Doorbell, Not a Queue: Build a Worker That Survives kill -9 - Book: Event-Driven Architecture Pocket Guide - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Postgres delivers a NOTIFY to whoever is listening at that instant. During a deploy of your order worker, for the few seconds between the old process stopping and the new one booting, that is nobody. The API keeps sending NOTIFY new_order, '{"id": ...}' for every checkout, and those orders never get processed. Nothing in the logs says so. That is the trap with Postgres LISTEN/NOTIFY. It ships with the database and it's fast, so it looks like a free message queue. It isn't one. Think of it as a doorbell that tells whoever happens to be home that something changed. The fix: jobs live in a table and NOTIFY only wakes the worker. A slow poll plus FOR UPDATE SKIP LOCKED makes a missed ring harmless. You'll build that worker in TypeScript with node-postgres, run two copies, kill one with kill -9 in the middle of a job, and check the table. Why NOTIFY is not a queue Read the NOTIFY docs with queue semantics in mind and four things jump out. It only reaches sessions that are listening right now. NOTIFY sends the event "to each client application that has previously executed LISTEN" on that channel. A worker that is restarting or stuck reconnecting is not one of them, and the docs describe no replay for latecomers. It is a broadcast. Every listener gets every notification. Run three workers that do the work named in the payload and each job runs three times. A queue gives each message to one consumer. The payload is small. "In the default configuration it must be shorter than 8000 bytes," and the docs themselves suggest putting larger data "in a database table and send the key of the record." The commit path has a global lock. In March 2025 Recall.ai had three outages in four days and traced them to AccessExclusiveLock on object 0 of class 1262 of database 0, a lock that a transaction which issued NOTIFY takes during COMMIT. Because it sits on "database 0", it covers the whole instance, so under many concurrent writers the commits line up one behind another. Listeners add their own cost. Christophe Pettus measured it in October 2026: through PostgreSQL 18, each notifying commit also signals every listener in the database. In his test, fifty idle listeners on an unrelated channel cut throughput from about 22,000 to about 3,700 transactions per second. PostgreSQL 19 wakes only the listeners on the notified channel plus any that have fallen far behind. It has been at release candidate since October 15, and Pettus tested a beta. Recall.ai has since added an update to its post pointing at the core commit behind that change and saying it "has eliminated the bottleneck." DBOS, which got NOTIFY up to 60K stream writes per second by batching, reads the same change more narrowly: it "does not remove the global lock." By DBOS's account, wake-ups got cheaper and the commit-time lock stayed. None of this makes NOTIFY useless. Treat it as a latency optimisation. Notice the design DBOS landed on: notifications to wake readers, plus a low-frequency poll in case a notification is lost. That's the doorbell pattern, and it's what you'll build here. The schema: the table is the queue Start a throwaway Postgres with the official Docker image: docker run --name jobs-pg -e POSTGRES_PASSWORD=pg \ -p 5432:5432 -d postgres:18 export PGUSER=postgres PGPASSWORD=pg PGDATABASE=postgres docker exec -it jobs-pg psql -U postgres node-postgres reads the same PG* environment variables as psql and defaults to port 5432 on the local machine, so the worker needs no connection string. In the psql session, create the table, an index for the hot path, and a trigger that rings the bell: CREATE TABLE jobs ( id bigserial PRIMARY KEY, payload jsonb NOT NULL, status text NOT NULL DEFAULT 'pending', attempts int NOT NULL DEFAULT 0, run_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX jobs_ready ON jobs (run_at) WHERE status = 'pending'; CREATE FUNCTION jobs_ring() RETURNS trigger AS $$ BEGIN PERFORM pg_notify('jobs', ''); RETURN NULL; END $$ LANGUAGE plpgsql; CREATE TRIGGER jobs_ring AFTER INSERT ON jobs FOR EACH STATEMENT EXECUTE FUNCTION jobs_ring(); Three choices in there are deliberate. The payload is an empty string. The worker never reads it, because the row already carries the data. That keeps you miles away from the 8000-byte limit and means a notification can't disagree with the table. The trigger is FOR EACH STATEMENT, so an INSERT ... SELECT of 500 jobs rings once, not 500 times. The docs also say identical notifications on the same channel within one transaction get folded into one, so even several insert statements in one transaction ring once. The trigger fires inside the producer's transaction. NOTIFY is only delivered on commit, so a rolled-back insert never rings and a worker never wakes up to a row it can't see. The worker Install the driver and tsx to run TypeScript directly. The worker uses top-level await, so mark the package as an ES module: npm init -y npm pkg set type=module npm install pg npm install -D tsx typescript @types/pg The worker is one file, worker.ts, built in three parts. Start it with the config, the two connections and a stand-in handler: import pg, { type PoolClient } from "pg"; const POLL_MS = 5_000; const MAX_ATTEMPTS = 5; type Job = { id: string; payload: unknown; attempts: number }; // both read PGUSER, PGPASSWORD, PGDATABASE from the environment const pool = new pg.Pool({ max: 2 }); const listener = new pg.Client(); let running = true; let rung = false; let wake = () => {}; async function handle(job: Job): Promise<void> { await new Promise((r) => setTimeout(r, 500)); // fake work console.log(`pid ${process.pid} did job ${job.id}`); } The pool has two connections for claiming jobs. The listener is a separate, long-lived Client, because a LISTEN belongs to the session that ran it. Append the claim logic: async function claimWith(db: PoolClient): Promise<boolean> { await db.query("BEGIN"); const { rows } = await db.query<Job>( `SELECT id, payload, attempts FROM jobs WHERE status = 'pending' AND run_at <= now() ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED`, ); const job = rows[0]; if (!job) { await db.query("COMMIT"); return false; } let status = "done"; try { await handle(job); } catch (err) { console.error(`job ${job.id} failed:`, err); const last = job.attempts + 1 >= MAX_ATTEMPTS; status = last ? "failed" : "pending"; } await db.query( `UPDATE jobs SET status = $2, attempts = attempts + 1, run_at = now() + make_interval(secs => 2 ^ attempts) WHERE id = $1`, [job.id, status], ); await db.query("COMMIT"); return true; } async function claimOne(): Promise<boolean> { const db = await pool.connect(); try { const worked = await claimWith(db); db.release(); return worked; } catch (err) { db.release(true); // closing it makes Postgres roll back throw err; } } Each claimOne call opens a transaction, locks one ready row with FOR UPDATE SKIP LOCKED, runs the handler, marks the row, and commits. SKIP LOCKED is what lets two workers share the table: rows another worker has locked are skipped instead of waited on. The SELECT docs call this "not suitable for general purpose work" but fine "to avoid lock contention with multiple consumers accessing a queue-like table", which is this table. A thrown handler error puts the job back to pending with run_at pushed out by 2^attempts seconds. After five attempts it parks in failed for a human to look at. If a query fails mid-job, release(true) destroys the connection instead of returning it to the pool, so Postgres rolls back and the row unlocks. The error then escapes the main loop and the process exits. Last part: the wait, the signal handlers and the main loop. function waitForBell(ms: number): Promise<void> { if (rung) return Promise.resolve(); return new Promise((resolve) => { const timer = setTimeout(resolve, ms); wake = () => { clearTimeout(timer); resolve(); }; }); } function stop() { running = false; wake(); } listener.on("notification", () => { rung = true; wake(); }); listener.on("error", (err) => { console.error("listener lost, exiting:", err.message); process.exit(1); // let the supervisor restart us }); pool.on("error", (err) => { console.error("idle client:", err.message); }); process.on("SIGINT", stop); process.on("SIGTERM", stop); await listener.connect(); await listener.query("LISTEN jobs"); // listen first, then look while (running) { rung = false; while (running && (await claimOne())) {} if (running) await waitForBell(POLL_MS); } await listener.end(); await pool.end(); Listen, then look. The worker runs LISTEN before its first drain. The LISTEN docs recommend exactly this order: register and commit first, then "inspect the database state", then rely on notifications for later changes. Do it the other way round and a job inserted between your check and your LISTEN sits there until the next poll. Then it drains until empty: the inner loop calls claimOne as long as it returns true. The rung flag closes the gap. The flag resets before each drain. If a notification lands while the worker is busy, rung flips to true and waitForBell returns at once, so the worker drains again instead of sleeping on a fresh job. The poll covers everything the bell misses. After POLL_MS with no ring, the worker drains anyway. That catches rings sent while it was down, retries whose run_at is in the future (nothing rings for those), and any notification lost for any other reason. The bell decides how fast you react. The poll decides whether you're correct. Crashes are boring on purpose. If the listener connection dies, or a claim query fails, the process exits and whatever supervises it starts a new one, which drains the backlog on boot. Kill it mid-stream Open two terminals with the three PG* variables exported, plus your psql session. In each terminal, start a worker: npx tsx worker.ts In psql, queue 50 jobs in one statement: INSERT INTO jobs (payload) SELECT jsonb_build_object('n', g) FROM generate_series(1, 50) AS g; Both workers wake on the single ring and start printing lines with different pids, since SKIP LOCKED hands each worker different rows. While they're going, find one worker's pid in its output and kill it hard: kill -9 <pid> The worker never gets to COMMIT. The OS closes its socket, Postgres rolls back the open transaction, the row lock goes away, and the job is pending again. The surviving worker picks it up on its next claim. Now the scenario from the opening. Stop the surviving worker with Ctrl-C (it finishes its current job, then exits), insert another 50 rows while no worker is running, and start one worker again. Nobody heard those rings. The new worker drains all 50 on boot anyway, because its first move is to look at the table. Then check: SELECT status, count(*) FROM jobs GROUP BY status; Every row should be done. In production, if a row sits in pending with a run_at well in the past, either no worker is running or the ones you have are stuck. That's a query you can alert on. You can't alert on a NOTIFY nobody received. What this design does not promise It is at-least-once, not exactly-once. If the worker dies after handle did its side effect but before COMMIT, the job runs again. Make the handler idempotent: key external calls on the job id. The transaction stays open for the whole job. That's fine for jobs measured in seconds. For long jobs it holds a connection per worker, and if the machine vanishes rather than the process, Postgres only notices the dead client when the socket finally errors, which depends on settings like tcp_keepalives_idle. For long jobs, claim with a lease and a heartbeat instead. This earlier post builds that version, with fencing tokens. The listener needs a real session. PgBouncer lists LISTEN as never supported in transaction pooling, so point the listener Client at Postgres directly or at a session-mode pool. The global commit lock still applies to every transaction that inserts a job. If job inserts are a big share of a busy database's writes, you can delete the trigger and run on the poll alone. Because the table is the source of truth, that's a one-line DROP TRIGGER, and the worker keeps running on the poll with up to POLL_MS of extra latency. --- If this was useful The doorbell-plus-mailbox split is one case of a rule the Event-Driven Architecture Pocket Guide keeps coming back to: the event says something happened, durable state says what. The book covers the same split for the outbox pattern, sagas and CQRS, plus the traps around retries, ordering and duplicate delivery that this worker only starts to handle. ![Event-Driven Architecture Pocket Guide: Saga, CQRS, Outbox, and the Traps Nobody Warns You About](amazon.com)

    3 reactions · 4 comments
  5. AbortSignal.timeout() and AbortSignal.any(): Cancel Any Async Call in TypeScript - Book: TypeScript Essentials - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Your function promised to give up after two seconds, and it did. It threw a nice Error("timed out"), the caller logged it, the user saw a retry button. Meanwhile the HTTP request it was supposed to abandon kept downloading for another forty seconds, holding a socket, filling a buffer nobody would ever read. That is what a hand-rolled timeout built on setTimeout and Promise.race does. It stops waiting. It never stops the work. The fix has been sitting in every runtime for a while. Node.js added AbortSignal.timeout() in v17.3.0 and v16.14.0, and AbortSignal.any() in v20.3.0 and v18.17.0. MDN marks AbortSignal.any() as available across browsers since March 2024, and AbortSignal.timeout() since April 2024. Deno and Bun document both too. Every snippet below is plain TypeScript that runs with node file.ts. Node's type stripping is on by default from v23.6.0 and v22.18.0, and stable since v25.2.0 and v24.12.0. On an older Node, use tsx. The snippets use top-level await, so run them in a folder whose package.json has "type": "module". The setTimeout-and-a-flag version, and what it leaks Here is the pattern you will find in a lot of codebases: // race.ts: the hand-rolled timeout async function getJson(url: string, ms: number) { let timedOut = false; const timer = new Promise<never>((_, reject) => { setTimeout(() => { timedOut = true; reject(new Error(`timed out after ${ms}ms`)); }, ms); }); const work = fetch(url).then((res) => { if (timedOut) return undefined; // too late, drop it return res.json(); }); return Promise.race([work, timer]); } Read it as the runtime does. Promise.race settles with whichever promise finishes first, and that is all it does. It has no way to reach into the fetch and stop it. The timedOut flag only protects your code from using a late result. The connection, the response stream and the JSON parse you skipped are still the runtime's problem. There is a second leak in the happy path. When work wins, the timer is never cleared, so the pending setTimeout keeps the process alive until it fires, long after the response is in. A CLI built this way sits there doing nothing until the timer runs out. Bun's own linter has a rule for the controller-plus-timer variant of this, prefer-timeout-signal, which flags an AbortController that only a setTimeout aborts. AbortSignal.timeout(): one call, one deadline fetch takes a signal option. Hand it a signal that aborts itself after a delay and the runtime tears down the request when the deadline hits: // timeout.ts try { const res = await fetch("https://example.com/", { signal: AbortSignal.timeout(2_000), }); const body = await res.text(); console.log(res.status, body.length); } catch (err) { console.error("request failed:", err); } There is no timer to clear and no flag to check. Node's docs describe it as returning "a new AbortSignal which will be aborted in delay milliseconds" (globals docs). The reason it aborts with matters for later. Per MDN, the signal's reason is a DOMException named TimeoutError. Deno's fetch timeout example spells out what that means for fetch: "When the deadline passes first, fetch rejects with a DOMException whose name is TimeoutError, not AbortError." A plain controller.abort() with no argument, by contrast, uses an AbortError DOMException as the reason (MDN). Keep res.text() inside the try. The body arrives after the headers, and a slow body is exactly the case a deadline exists for. One trap: the clock starts when you call AbortSignal.timeout(), not when you pass the signal to something. Create it per call. A module-level const DEADLINE = AbortSignal.timeout(5_000) is already aborted for every request made after the first five seconds of the process. AbortSignal.any(): a user cancel and a deadline on one signal Real calls rarely have one reason to stop. The user hits cancel, the server is shutting down, or the deadline passes. AbortSignal.any() takes a list of signals and returns one that aborts when any of them does. Per MDN, its reason is the reason of the first input that aborted. // any.ts const user = new AbortController(); process.once("SIGINT", () => user.abort()); const signal = AbortSignal.any([ user.signal, AbortSignal.timeout(10_000), ]); try { const res = await fetch("https://example.com/", { signal }); console.log(res.status, (await res.text()).length); } catch (err) { console.error("stopped:", signal.reason); } Press Ctrl-C during the request and the combined signal aborts with the AbortError from user.abort(). If the request is still running at ten seconds, it aborts with the TimeoutError. This composes well with retries. Each attempt gets its own short deadline, and all of them share the user's cancel and one overall budget: // retry.ts async function getWithRetry( url: string, outer: AbortSignal, ): Promise<Response> { const budget = AbortSignal.timeout(8_000); for (let attempt = 1; ; attempt++) { const signal = AbortSignal.any([ outer, budget, AbortSignal.timeout(2_000), ]); try { return await fetch(url, { signal }); } catch (err) { // the caller or the budget gave up: stop retrying if (outer.aborted || budget.aborted) throw err; if (attempt === 3) throw err; } } } const user = new AbortController(); const res = await getWithRetry( "https://example.com/", user.signal, ); console.log(res.status); The returned body is still bound to those deadlines. If it can be slow, read it inside getWithRetry, or the 2-second attempt timer can fire mid-download. Notice the check inside catch. It asks the signals what happened instead of guessing from the error. Make your own async functions cancellable Passing signal to fetch covers the network. Your own loops, polls and multi-step jobs need to cooperate too, and signal.throwIfAborted() is the one-liner for that. It throws the signal's reason if the signal has aborted and does nothing otherwise (MDN). The pattern: accept a signal, check it before each unit of work, and pass it down to everything that accepts one. // poll.ts import { setTimeout as sleep } from "node:timers/promises"; type Job = { id: string; done: boolean }; export async function waitForJob( check: (signal: AbortSignal) => Promise<Job>, signal: AbortSignal, ): Promise<Job> { while (true) { signal.throwIfAborted(); const job = await check(signal); if (job.done) return job; await sleep(500, undefined, { signal }); } } let calls = 0; const check = async (): Promise<Job> => { calls += 1; return { id: "job-1", done: calls > 100 }; }; const deadline = AbortSignal.timeout(1_200); try { await waitForJob(check, deadline); } catch (err) { console.error("gave up:", deadline.reason); } Three places can stop this loop: the throwIfAborted() at the top of each iteration, the check function if it forwards the signal to a fetch, and the sleep. timers/promises accepts a signal and, when it is cancelled, rejects with an 'AbortError'. The deadline above fires while the loop is sleeping, so the error that reaches your catch comes from the sleep. Node's errors docs say Node APIs throw "a Node.js AbortError", a regular Error with name 'AbortError' and code 'ABORT_ERR'. Its name says cancelled even though the cause was a timeout. The reliable place to look is deadline.reason. Narrow the error without a cast With strict on, the variable in a catch clause is unknown (useUnknownInCatchVariables, TypeScript 4.4), and err.name does not compile. The usual escape hatch is (err as DOMException).name, which compiles and lies whenever the thing thrown was a Node AbortError, a TypeError from a refused connection, or the string you passed to controller.abort("shutdown"). The reason can be any JavaScript value. TypeScript 4.9 made in narrowing work on types that do not list the property, which is all you need to read a name safely: // abort-kind.ts export type AbortKind = "timeout" | "cancelled" | "other"; export function errorName(err: unknown): string | undefined { if ( typeof err === "object" && err !== null && "name" in err && typeof err.name === "string" ) { return err.name; } return undefined; } export function abortKind( err: unknown, signal: AbortSignal, ): AbortKind { if (signal.aborted) { const why = errorName(signal.reason); return why === "TimeoutError" ? "timeout" : "cancelled"; } const name = errorName(err); if (name === "TimeoutError") return "timeout"; if (name === "AbortError") return "cancelled"; return "other"; } abortKind checks the signal first. If the signal you own has aborted, its reason is the truth, whatever the error looks like by the time it bubbles up through a library or a Node timer. Only when the signal is still live does it fall back to the error's name, which covers a dependency that aborted on its own. Anything else is a real failure: a network error, a 500 you turned into an exception, a bug. In practice the call site reads like this: // use-kind.ts import { abortKind } from "./abort-kind.ts"; const user = new AbortController(); const signal = AbortSignal.any([ user.signal, AbortSignal.timeout(2_000), ]); try { const res = await fetch("https://example.com/", { signal }); console.log(res.status); } catch (err) { const kind = abortKind(err, signal); if (kind === "other") throw err; console.log(`request ${kind}`); } The .ts extension in the import is required when Node strips types itself (Node docs). No casts. If someone later throws a custom error class or aborts with a plain string, the code still type-checks and still lands in a sensible branch. Where this goes wrong A few edges worth knowing before you roll it out. The timeout counts active time. MDN notes the timer is effectively paused in a suspended worker or while a page sits in the back-forward cache (MDN). For a browser tab that comes back from bfcache, "two seconds" means two seconds of running. A combined signal cannot be detached. MDN says there is no way to unsubscribe the result of AbortSignal.any() from its inputs, and that a non-aborted combined signal with abort listeners stays alive while its sources do. If you add a listener, remove it when the work finishes: // listen.ts export function onAbort( signal: AbortSignal, fn: () => void, ): () => void { signal.addEventListener("abort", fn, { once: true }); return () => signal.removeEventListener("abort", fn); } Call the returned function in a finally. That matters most for a long-lived signal, such as one tied to server shutdown, that thousands of requests combine with. Code that ignores the signal keeps running. A synchronous CPU loop, a library without a signal option, or a function that forgets to pass it down will finish its work no matter what. Thread the signal through every layer you own and put throwIfAborted() between steps that do not take one. Type checking. node file.ts strips types without checking them. When you run tsc, the declarations come from @types/node, which declares any(), timeout() and throwIfAborted(), or from the dom lib in a browser project. Set allowImportingTsExtensions (with noEmit) or rewriteRelativeImportExtensions so tsc accepts the .ts imports. --- If this was useful Most of this post is two things TypeScript Essentials spends real time on: async code that cleans up after itself, and narrowing an unknown into something you can trust without reaching for as. If the abortKind helper was the useful part, that book is the place to go next. If you already live in generics and want the type-level side, The TypeScript Type System picks up where it stops. - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR Books 1 and 2 are the core path. Coming from Kotlin or Java, or from PHP 8+, swap in book 3 or 4 for the first one. Book 5 is for anyone shipping TypeScript at work. I also build Hermes IDE (GitHub), an IDE for developers who ship with Claude Code and other AI coding tools, so weigh this mention with that in mind. All five books ship in ebook, paperback, and hardcover. ![The TypeScript Library — the 5-book collection](xgabriel.com)

    1 reaction · 4 comments

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
privatenumber/tsx mentions in the last 30 days