[{"data":1,"prerenderedAt":1863},["ShallowReactive",2],{"writing":3},[4,117,556,675,835,978,1105,1228,1595,1735],{"id":5,"title":6,"body":7,"date":105,"description":13,"draft":106,"extension":107,"meta":108,"navigation":109,"path":110,"readTime":111,"seo":112,"snippet":113,"stem":114,"tag":115,"__hash__":116},"writing\u002Fwriting\u002Fthe-quiet-death-of-the-bookmark-bar.md","The quiet death of the bookmark bar",{"type":8,"value":9,"toc":98},"minimark",[10,14,17,22,25,48,51,55,63,66,73,77,80,87],[11,12,13],"p",{},"There was a time — somewhere between the second browser war and whatever we're in now — where the bookmark bar was a real piece of furniture. Mine had a folder for \"stuff to read later\", another for \"tools I remember using once\", and a miscellaneous bucket that always ended up being 70% of the total.",[11,15,16],{},"That entire mental model is quietly dying.",[18,19,21],"h2",{"id":20},"what-replaced-it","What replaced it",[11,23,24],{},"Three things crept in and replaced saved tabs, one at a time:",[26,27,28,36,42],"ul",{},[29,30,31,35],"li",{},[32,33,34],"strong",{},"Browser sync"," pulled everything into an account, which made \"losing tabs\" stop feeling scary",[29,37,38,41],{},[32,39,40],{},"Notion, Obsidian, Raindrop"," made bookmarking feel like real knowledge management, so people stopped doing it casually",[29,43,44,47],{},[32,45,46],{},"AI summaries"," ate the intent behind most bookmarks — \"save for later\" → \"just ask it later\"",[11,49,50],{},"Each of those is an improvement in isolation. Together, they routed the habit of saving-to-revisit into three different tools that don't talk to each other.",[18,52,54],{"id":53},"what-we-lose","What we lose",[11,56,57,58,62],{},"Bookmarks were an ",[59,60,61],"em",{},"ambient"," artifact. You'd glance at the bar while typing a URL and remember: right, I meant to read that.",[11,64,65],{},"Notes-apps don't do ambient. They wait for you to open them. Raindrop works, but it moves the collection out of the browser chrome where it used to live.",[11,67,68,69,72],{},"The specific loss: ",[32,70,71],{},"accidental rediscovery",". You found things by seeing them, not by searching. Search assumes you remember what you saved; serendipity doesn't.",[18,74,76],{"id":75},"what-im-trying","What I'm trying",[11,78,79],{},"I've gone back to a plain bookmarks bar, curated under 20 items, one folder deep. Every Sunday I delete anything I haven't clicked in a month.",[11,81,82,83,86],{},"It's a tiny weekly garbage collection and it works. The bookmarks bar becomes a ",[59,84,85],{},"current interests"," indicator, not a graveyard of good intentions.",[11,88,89,90,93,94,97],{},"If there's a larger lesson, it's this: interfaces that ",[59,91,92],{},"show"," you things you saved are better than interfaces that ",[59,95,96],{},"store"," them. We've optimised for storage and forgotten about surfacing.",{"title":99,"searchDepth":100,"depth":100,"links":101},"",2,[102,103,104],{"id":20,"depth":100,"text":21},{"id":53,"depth":100,"text":54},{"id":75,"depth":100,"text":76},"2025-03-14",false,"md",{},true,"\u002Fwriting\u002Fthe-quiet-death-of-the-bookmark-bar","6 min",{"title":6,"description":13},"On the slow migration from saved tabs to AI-summarized inboxes, and what we lose along the way.","writing\u002Fthe-quiet-death-of-the-bookmark-bar","essay","UoU4nNdxOlDXSekBd62CxJe4iAtB2wQ4avtXrFIQuRU",{"id":118,"title":119,"body":120,"date":547,"description":124,"draft":106,"extension":107,"meta":548,"navigation":109,"path":549,"readTime":550,"seo":551,"snippet":552,"stem":553,"tag":554,"__hash__":555},"writing\u002Fwriting\u002Fbuilding-a-search-index-in-200-lines-of-typescript.md","Building a search index in 200 lines of TypeScript",{"type":8,"value":121,"toc":541},[122,125,139,143,146,207,210,476,486,490,493,496,510,520,524,527,531,534,537],[11,123,124],{},"Every few months I see someone reach for Elasticsearch to add search to a site that has maybe three thousand documents. This is roughly equivalent to buying a crane to hang a picture.",[11,126,127,128,131,132,131,135,138],{},"Most sites need three things from search: ",[59,129,130],{},"find documents containing a word",", ",[59,133,134],{},"rank by relevance",[59,136,137],{},"make it fast enough that nobody notices",". All three fit comfortably in ~200 lines of TypeScript.",[18,140,142],{"id":141},"the-inverted-index","The inverted index",[11,144,145],{},"Forget everything about databases for a second. The core data structure in search is embarrassingly simple:",[147,148,152],"pre",{"className":149,"code":150,"language":151,"meta":99,"style":99},"language-ts shiki shiki-themes material-theme-lighter github-dark-dimmed github-dark-dimmed","type Index = Map\u003Cstring, Map\u003CDocId, number>>\n\u002F\u002F term -> { docId -> frequency }\n","ts",[153,154,155,201],"code",{"__ignoreMap":99},[156,157,160,164,168,172,175,179,183,186,188,190,193,195,198],"span",{"class":158,"line":159},"line",1,[156,161,163],{"class":162},"sdLfj","type",[156,165,167],{"class":166},"sK2ye"," Index",[156,169,171],{"class":170},"sHeb4"," =",[156,173,174],{"class":166}," Map",[156,176,178],{"class":177},"sDCYr","\u003C",[156,180,182],{"class":181},"sa6Jl","string",[156,184,185],{"class":177},",",[156,187,174],{"class":166},[156,189,178],{"class":177},[156,191,192],{"class":166},"DocId",[156,194,185],{"class":177},[156,196,197],{"class":181}," number",[156,199,200],{"class":177},">>\n",[156,202,203],{"class":158,"line":100},[156,204,206],{"class":205},"svbSo","\u002F\u002F term -> { docId -> frequency }\n",[11,208,209],{},"For every word, you keep a list of documents containing it and how often. That's it. Building it is two nested loops over your documents.",[147,211,213],{"className":149,"code":212,"language":151,"meta":99,"style":99},"function buildIndex(docs: Doc[]): Index {\n  const idx: Index = new Map()\n  for (const doc of docs) {\n    for (const term of tokenize(doc.text)) {\n      const postings = idx.get(term) ?? new Map()\n      postings.set(doc.id, (postings.get(doc.id) ?? 0) + 1)\n      idx.set(term, postings)\n    }\n  }\n  return idx\n}\n",[153,214,215,251,275,303,337,371,429,449,455,461,470],{"__ignoreMap":99},[156,216,217,220,224,227,231,234,237,241,244,246,248],{"class":158,"line":159},[156,218,219],{"class":162},"function",[156,221,223],{"class":222},"syDMH"," buildIndex",[156,225,226],{"class":177},"(",[156,228,230],{"class":229},"spDPk","docs",[156,232,233],{"class":170},":",[156,235,236],{"class":166}," Doc",[156,238,240],{"class":239},"sw5_h","[]",[156,242,243],{"class":177},")",[156,245,233],{"class":170},[156,247,167],{"class":166},[156,249,250],{"class":177}," {\n",[156,252,253,256,260,262,264,266,269,271],{"class":158,"line":100},[156,254,255],{"class":162},"  const",[156,257,259],{"class":258},"sYtvh"," idx",[156,261,233],{"class":170},[156,263,167],{"class":166},[156,265,171],{"class":170},[156,267,268],{"class":170}," new",[156,270,174],{"class":222},[156,272,274],{"class":273},"sLx6u","()\n",[156,276,278,282,285,288,291,294,297,300],{"class":158,"line":277},3,[156,279,281],{"class":280},"sY_7v","  for",[156,283,284],{"class":273}," (",[156,286,287],{"class":162},"const",[156,289,290],{"class":258}," doc",[156,292,293],{"class":170}," of",[156,295,296],{"class":239}," docs",[156,298,299],{"class":273},") ",[156,301,302],{"class":177},"{\n",[156,304,306,309,311,313,316,318,321,323,326,329,332,335],{"class":158,"line":305},4,[156,307,308],{"class":280},"    for",[156,310,284],{"class":273},[156,312,287],{"class":162},[156,314,315],{"class":258}," term",[156,317,293],{"class":170},[156,319,320],{"class":222}," tokenize",[156,322,226],{"class":273},[156,324,325],{"class":239},"doc",[156,327,328],{"class":177},".",[156,330,331],{"class":239},"text",[156,333,334],{"class":273},")) ",[156,336,302],{"class":177},[156,338,340,343,346,348,350,352,355,357,360,362,365,367,369],{"class":158,"line":339},5,[156,341,342],{"class":162},"      const",[156,344,345],{"class":258}," postings",[156,347,171],{"class":170},[156,349,259],{"class":239},[156,351,328],{"class":177},[156,353,354],{"class":222},"get",[156,356,226],{"class":273},[156,358,359],{"class":239},"term",[156,361,299],{"class":273},[156,363,364],{"class":170},"??",[156,366,268],{"class":170},[156,368,174],{"class":222},[156,370,274],{"class":273},[156,372,374,377,379,382,384,386,388,391,393,395,398,400,402,404,406,408,410,412,414,418,420,423,426],{"class":158,"line":373},6,[156,375,376],{"class":239},"      postings",[156,378,328],{"class":177},[156,380,381],{"class":222},"set",[156,383,226],{"class":273},[156,385,325],{"class":239},[156,387,328],{"class":177},[156,389,390],{"class":239},"id",[156,392,185],{"class":177},[156,394,284],{"class":273},[156,396,397],{"class":239},"postings",[156,399,328],{"class":177},[156,401,354],{"class":222},[156,403,226],{"class":273},[156,405,325],{"class":239},[156,407,328],{"class":177},[156,409,390],{"class":239},[156,411,299],{"class":273},[156,413,364],{"class":170},[156,415,417],{"class":416},"sqQCU"," 0",[156,419,299],{"class":273},[156,421,422],{"class":170},"+",[156,424,425],{"class":416}," 1",[156,427,428],{"class":273},")\n",[156,430,432,435,437,439,441,443,445,447],{"class":158,"line":431},7,[156,433,434],{"class":239},"      idx",[156,436,328],{"class":177},[156,438,381],{"class":222},[156,440,226],{"class":273},[156,442,359],{"class":239},[156,444,185],{"class":177},[156,446,345],{"class":239},[156,448,428],{"class":273},[156,450,452],{"class":158,"line":451},8,[156,453,454],{"class":177},"    }\n",[156,456,458],{"class":158,"line":457},9,[156,459,460],{"class":177},"  }\n",[156,462,464,467],{"class":158,"line":463},10,[156,465,466],{"class":280},"  return",[156,468,469],{"class":239}," idx\n",[156,471,473],{"class":158,"line":472},11,[156,474,475],{"class":177},"}\n",[11,477,478,479,482,483,328],{},"Searching is just a set intersection of the postings lists for each query term. If that sentence sounded scary in school, it's worth noticing you already use ",[153,480,481],{},"Map"," and ",[153,484,485],{},"Set",[18,487,489],{"id":488},"ranking","Ranking",[11,491,492],{},"Raw matches are useless (\"the\" matches everything). You want BM25 — a ranking function that rewards rare terms, penalises long documents, and was designed in the late 70s.",[11,494,495],{},"The full formula looks intimidating. The intuition isn't:",[497,498,499],"blockquote",{},[11,500,501,502,505,506,509],{},"A term is more relevant the more it appears in ",[59,503,504],{},"this"," document, and less relevant the more it appears in ",[59,507,508],{},"every"," document.",[11,511,512,513,131,516,519],{},"That's it. BM25 formalises that intuition with two tuning constants (",[153,514,515],{},"k1",[153,517,518],{},"b",") that almost no one needs to tune.",[18,521,523],{"id":522},"performance","Performance",[11,525,526],{},"For a corpus under ~50k documents, the index fits in memory, queries are sub-millisecond, and you never need to think about it again. The day you cross that threshold is the day to look at a real engine. Until then, you're running a distributed cluster to power a search box that queries less than a DSLR camera's worth of text.",[18,528,530],{"id":529},"when-not-to-do-this","When not to do this",[11,532,533],{},"Don't roll your own if you need fuzzy matching, multi-language tokenisation, or faceted filtering at scale. Don't roll your own if someone else on the team has to maintain it.",[11,535,536],{},"But for a blog, a docs site, a small internal tool — 200 lines is less code than the YAML you'd write to configure Elasticsearch.",[538,539,540],"style",{},"html pre.shiki code .sdLfj, html code.shiki .sdLfj{--shiki-light:#9C3EDA;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sK2ye, html code.shiki .sK2ye{--shiki-light:#E2931D;--shiki-default:#F69D50;--shiki-dark:#F69D50}html pre.shiki code .sHeb4, html code.shiki .sHeb4{--shiki-light:#39ADB5;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sDCYr, html code.shiki .sDCYr{--shiki-light:#39ADB5;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sa6Jl, html code.shiki .sa6Jl{--shiki-light:#E2931D;--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .svbSo, html code.shiki .svbSo{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#768390;--shiki-default-font-style:inherit;--shiki-dark:#768390;--shiki-dark-font-style:inherit}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .syDMH, html code.shiki .syDMH{--shiki-light:#6182B8;--shiki-default:#DCBDFB;--shiki-dark:#DCBDFB}html pre.shiki code .spDPk, html code.shiki .spDPk{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#F69D50;--shiki-default-font-style:inherit;--shiki-dark:#F69D50;--shiki-dark-font-style:inherit}html pre.shiki code .sw5_h, html code.shiki .sw5_h{--shiki-light:#90A4AE;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sYtvh, html code.shiki .sYtvh{--shiki-light:#90A4AE;--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .sLx6u, html code.shiki .sLx6u{--shiki-light:#E53935;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sY_7v, html code.shiki .sY_7v{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#F47067;--shiki-default-font-style:inherit;--shiki-dark:#F47067;--shiki-dark-font-style:inherit}html pre.shiki code .sqQCU, html code.shiki .sqQCU{--shiki-light:#F76D47;--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}",{"title":99,"searchDepth":100,"depth":100,"links":542},[543,544,545,546],{"id":141,"depth":100,"text":142},{"id":488,"depth":100,"text":489},{"id":522,"depth":100,"text":523},{"id":529,"depth":100,"text":530},"2025-02-02",{},"\u002Fwriting\u002Fbuilding-a-search-index-in-200-lines-of-typescript","12 min",{"title":119,"description":124},"A walkthrough of inverted indices, BM25 scoring, and why you probably do not need Elasticsearch.","writing\u002Fbuilding-a-search-index-in-200-lines-of-typescript","engineering","rz1ZDTjOyWB8c2lg44sSHlf1OwJ2UwlVFoqTzGH-340",{"id":557,"title":558,"body":559,"date":665,"description":666,"draft":106,"extension":107,"meta":667,"navigation":109,"path":668,"readTime":669,"seo":670,"snippet":671,"stem":672,"tag":673,"__hash__":674},"writing\u002Fwriting\u002Fnotes-on-writing-a-cli-people-will-actually-use.md","Notes on writing a CLI people will actually use",{"type":8,"value":560,"toc":658},[561,567,570,574,581,584,588,591,596,599,603,606,613,616,622,626,648,651,655],[11,562,563,564,566],{},"Most CLIs fail at being CLIs the moment they start acting like applications. A good CLI is a ",[59,565,219],{}," you can call from a terminal; a bad one is an app you have to learn.",[11,568,569],{},"Two years of building internal tools, a pile of feedback from people who'd rather not be using my stuff, here's what actually moves the needle.",[18,571,573],{"id":572},"defaults-are-the-product","Defaults are the product",[11,575,576,577,580],{},"The default command is 80% of the user experience. If typing ",[153,578,579],{},"mytool"," without arguments doesn't do something useful — print help, run the common case, explain itself — the rest of the polish doesn't matter.",[11,582,583],{},"Whatever the most common subcommand is, make it the default. Don't hide behind subcommands for their own sake.",[18,585,587],{"id":586},"flags-should-be-rare","Flags should be rare",[11,589,590],{},"Every flag is a decision I'm asking the user to make. Users don't want to make decisions; they want their thing to work.",[497,592,593],{},[11,594,595],{},"\"The best flag is the one that doesn't need to exist.\"",[11,597,598],{},"If two flags are almost always set together, collapse them. If a flag has a sensible default 95% of the time, set that default and let the rare case override.",[18,600,602],{"id":601},"errors-are-documentation","Errors are documentation",[11,604,605],{},"When something fails, the error message is the only documentation most users will read. Write it like a human:",[147,607,611],{"className":608,"code":610,"language":331},[609],"language-text","✗ Cannot deploy — your git working tree has uncommitted changes.\n  Run `git status` to see them, commit, then retry.\n",[153,612,610],{"__ignoreMap":99},[11,614,615],{},"Not:",[147,617,620],{"className":618,"code":619,"language":331},[609],"Error: WORKING_TREE_DIRTY\n",[153,621,619],{"__ignoreMap":99},[18,623,625],{"id":624},"the-three-commands-most-clis-forget","The three commands most CLIs forget",[26,627,628,633,642],{},[29,629,630],{},[153,631,632],{},"mytool --version",[29,634,635,638,639,243],{},[153,636,637],{},"mytool --help"," (and ",[153,640,641],{},"mytool subcommand --help",[29,643,644,647],{},[153,645,646],{},"mytool doctor"," — or whatever you call \"is my environment OK?\"",[11,649,650],{},"The first two are table stakes. The third is the one that separates polite CLIs from great ones.",[18,652,654],{"id":653},"one-last-thing","One last thing",[11,656,657],{},"Ship with one good example in the README. Not three. Not a reference. One, annotated line-by-line. That's the example people will copy, modify, and base their mental model on.",{"title":99,"searchDepth":100,"depth":100,"links":659},[660,661,662,663,664],{"id":572,"depth":100,"text":573},{"id":586,"depth":100,"text":587},{"id":601,"depth":100,"text":602},{"id":624,"depth":100,"text":625},{"id":653,"depth":100,"text":654},"2025-01-09","Most CLIs fail at being CLIs the moment they start acting like applications. A good CLI is a function you can call from a terminal; a bad one is an app you have to learn.",{},"\u002Fwriting\u002Fnotes-on-writing-a-cli-people-will-actually-use","8 min",{"title":558,"description":666},"Defaults matter more than flags. A short opinion piece informed by two years shipping internal tools.","writing\u002Fnotes-on-writing-a-cli-people-will-actually-use","craft","raf9LsjRoC5hY1Wlb_RumMyLXxtm3gaPFIu5YDVq_Kw",{"id":676,"title":677,"body":678,"date":826,"description":827,"draft":106,"extension":107,"meta":828,"navigation":109,"path":829,"readTime":830,"seo":831,"snippet":832,"stem":833,"tag":554,"__hash__":834},"writing\u002Fwriting\u002Fwhen-to-break-the-type-system.md","When to break the type system",{"type":8,"value":679,"toc":820},[680,687,691,694,701,705,708,728,738,742,745,756,759,767,771,781,814,817],[11,681,682,683,686],{},"There's a category of TypeScript user who treats ",[153,684,685],{},"as any"," like an unforgivable sin. They will spend a full afternoon wrestling a generic constraint to avoid one. That user is usually me, and I'm increasingly convinced I'm wrong about it.",[18,688,690],{"id":689},"what-the-type-system-is-for","What the type system is for",[11,692,693],{},"The type system exists to prevent a specific class of bugs: wrong shape at call sites, wrong assumption about what a value can be. That's a job it does well.",[11,695,696,697,700],{},"It is not a substitute for tests. It is not a guarantee of correctness. It does not stop runtime data from being garbage. If you've ever parsed a JSON response and declared it ",[153,698,699],{},"User",", you've told the compiler a story that might be a lie.",[18,702,704],{"id":703},"the-honest-escape-hatches","The honest escape hatches",[11,706,707],{},"TypeScript gives you three reasonable ways out:",[26,709,710,716,722],{},[29,711,712,715],{},[153,713,714],{},"as X"," — \"trust me, this is X\"",[29,717,718,721],{},[153,719,720],{},"X | unknown"," — \"this is X or I don't know, caller checks\"",[29,723,724,727],{},[153,725,726],{},"@ts-expect-error"," — \"this line is wrong, and I know it\"",[11,729,730,731,733,734,737],{},"All three are fine. ",[153,732,726],{}," is underused. Unlike ",[153,735,736],{},"@ts-ignore",", it complains when the underlying issue is fixed — so it self-cleans.",[18,739,741],{"id":740},"when-to-use-them","When to use them",[11,743,744],{},"I reach for escape hatches when:",[26,746,747,750,753],{},[29,748,749],{},"An external library's types are wrong, and upstreaming a fix would block shipping",[29,751,752],{},"A migration is mid-flight and the correct type is three refactors away",[29,754,755],{},"Narrowing would require a helper function whose only purpose is to appease the compiler",[11,757,758],{},"I don't use them when:",[26,760,761,764],{},[29,762,763],{},"I don't understand why the type error is happening (that's the compiler doing its job)",[29,765,766],{},"The fix is actually five minutes away",[18,768,770],{"id":769},"the-real-cost","The real cost",[11,772,773,774,776,777,780],{},"The cost of ",[153,775,685],{}," isn't in that line — it's in the next person who has to modify the code around it. If I leave a note ",[59,778,779],{},"why",", most of that cost disappears:",[147,782,784],{"className":149,"code":783,"language":151,"meta":99,"style":99},"\u002F\u002F upstream type is incorrect, PR: library\u002Ffoo#123\nconst user = res.data as User\n",[153,785,786,791],{"__ignoreMap":99},[156,787,788],{"class":158,"line":159},[156,789,790],{"class":205},"\u002F\u002F upstream type is incorrect, PR: library\u002Ffoo#123\n",[156,792,793,795,798,800,803,805,808,811],{"class":158,"line":100},[156,794,287],{"class":162},[156,796,797],{"class":258}," user",[156,799,171],{"class":170},[156,801,802],{"class":239}," res",[156,804,328],{"class":177},[156,806,807],{"class":239},"data ",[156,809,810],{"class":280},"as",[156,812,813],{"class":166}," User\n",[11,815,816],{},"A 25-character comment buys you a lot of forgiveness from future-you. Use it.",[538,818,819],{},"html pre.shiki code .svbSo, html code.shiki .svbSo{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#768390;--shiki-default-font-style:inherit;--shiki-dark:#768390;--shiki-dark-font-style:inherit}html pre.shiki code .sdLfj, html code.shiki .sdLfj{--shiki-light:#9C3EDA;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sYtvh, html code.shiki .sYtvh{--shiki-light:#90A4AE;--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .sHeb4, html code.shiki .sHeb4{--shiki-light:#39ADB5;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sw5_h, html code.shiki .sw5_h{--shiki-light:#90A4AE;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sDCYr, html code.shiki .sDCYr{--shiki-light:#39ADB5;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sY_7v, html code.shiki .sY_7v{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#F47067;--shiki-default-font-style:inherit;--shiki-dark:#F47067;--shiki-dark-font-style:inherit}html pre.shiki code .sK2ye, html code.shiki .sK2ye{--shiki-light:#E2931D;--shiki-default:#F69D50;--shiki-dark:#F69D50}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":99,"searchDepth":100,"depth":100,"links":821},[822,823,824,825],{"id":689,"depth":100,"text":690},{"id":703,"depth":100,"text":704},{"id":740,"depth":100,"text":741},{"id":769,"depth":100,"text":770},"2024-11-22","There's a category of TypeScript user who treats as any like an unforgivable sin. They will spend a full afternoon wrestling a generic constraint to avoid one. That user is usually me, and I'm increasingly convinced I'm wrong about it.",{},"\u002Fwriting\u002Fwhen-to-break-the-type-system","5 min",{"title":677,"description":827},"`as any` is not a moral failure. A defense of pragmatic escape hatches in long-lived codebases.","writing\u002Fwhen-to-break-the-type-system","u3aA99RPdPFvFy_35hnqhBMxDruVLP-0YY-bv_66SwA",{"id":836,"title":837,"body":838,"date":970,"description":842,"draft":106,"extension":107,"meta":971,"navigation":109,"path":972,"readTime":973,"seo":974,"snippet":975,"stem":976,"tag":554,"__hash__":977},"writing\u002Fwriting\u002Fa-weekend-with-bun-deno-and-node.md","A weekend with Bun, Deno, and Node",{"type":8,"value":839,"toc":963},[840,843,846,850,861,868,872,878,885,888,892,902,909,920,923,927,933,936,940,960],[11,841,842],{},"I took a small Saturday project — a Markdown-to-HTML renderer with a file watcher — and built it three times, once on each major JavaScript runtime. Same input, same output, just the runtime swapped.",[11,844,845],{},"The results are less \"which one wins\" and more \"which one loses the least.\"",[18,847,849],{"id":848},"the-project","The project",[11,851,852,853,856,857,860],{},"~350 lines. Reads a directory of ",[153,854,855],{},".md"," files, renders them to HTML with a naive pipeline, writes them to ",[153,858,859],{},"dist\u002F",". Watch mode on, incremental by mtime. Nothing fancy.",[11,862,863,864,867],{},"Dependencies: just a Markdown parser (the one with the cleanest install for each runtime — ",[153,865,866],{},"marked"," for Node, native imports for Deno, Bun's built-in parser).",[18,869,871],{"id":870},"startup","Startup",[147,873,876],{"className":874,"code":875,"language":331},[609],"node:  168ms\ndeno:   94ms\nbun:    38ms\n",[153,877,875],{"__ignoreMap":99},[11,879,880,881,884],{},"Bun wins cold-start by a wide margin, as everyone says. What surprised me was how much of Node's startup is module resolution and not V8 warmup — running the same script under ",[153,882,883],{},"node --experimental-wasm-modules"," shaved ~40ms.",[11,886,887],{},"For a daemon that runs once, startup doesn't matter. For a CLI I invoke 50 times a day in a file watcher, it matters a lot.",[18,889,891],{"id":890},"watch-mode","Watch mode",[11,893,894,895,898,899,328],{},"Node's ",[153,896,897],{},"fs.watch"," is famously unreliable on macOS — debouncing events is on you. I ended up using ",[153,900,901],{},"chokidar",[11,903,904,905,908],{},"Deno's ",[153,906,907],{},"Deno.watchFs"," is cleaner API, but it still emits 4 events for one save on my Mac.",[11,910,911,912,915,916,919],{},"Bun's ",[153,913,914],{},"Bun.file"," + ",[153,917,918],{},"watch"," is the nicest API of the three, and also the most opaque when it misbehaves. I lost 40 minutes to a watcher that silently stopped firing after a directory rename.",[11,921,922],{},"Winner on ergonomics: Bun. Winner on \"I can actually debug when it breaks\": Node.",[18,924,926],{"id":925},"ecosystem","Ecosystem",[11,928,929,930,328],{},"This is the thing nobody writing about alternatives wants to say out loud: ",[32,931,932],{},"Node's ecosystem is a moat the others won't close for years",[11,934,935],{},"Every runtime had to install at least one npm package. Bun handled that transparently. Deno 2's Node compat layer mostly worked. But the moment I hit a native module (sharp), the gradient was steep.",[18,937,939],{"id":938},"what-id-actually-pick","What I'd actually pick",[26,941,942,948,954],{},[29,943,944,947],{},[32,945,946],{},"One-off CLI tool",": Bun. The install-to-first-run loop is half the time of Node.",[29,949,950,953],{},[32,951,952],{},"Long-lived service",": Node. Boring, stable, every library works.",[29,955,956,959],{},[32,957,958],{},"Edge\u002Fserverless",": Deno Deploy or Cloudflare Workers. Neither is on my list above, but that's the real niche both are winning.",[11,961,962],{},"Three runtimes; use cases don't overlap as much as the marketing suggests.",{"title":99,"searchDepth":100,"depth":100,"links":964},[965,966,967,968,969],{"id":848,"depth":100,"text":849},{"id":870,"depth":100,"text":871},{"id":890,"depth":100,"text":891},{"id":925,"depth":100,"text":926},{"id":938,"depth":100,"text":939},"2024-10-04",{},"\u002Fwriting\u002Fa-weekend-with-bun-deno-and-node","15 min",{"title":837,"description":842},"Same project, three runtimes, a benchmark, and the parts that surprised me about each.","writing\u002Fa-weekend-with-bun-deno-and-node","a6zRbA-_C8XhGLhBO6rBXHW57rd7APpZJy3WoO9EOGs",{"id":979,"title":980,"body":981,"date":1097,"description":985,"draft":106,"extension":107,"meta":1098,"navigation":109,"path":1099,"readTime":1100,"seo":1101,"snippet":1102,"stem":1103,"tag":998,"__hash__":1104},"writing\u002Fwriting\u002Fdesigning-for-the-cold-start.md","Designing for the cold start",{"type":8,"value":982,"toc":1091},[983,986,989,993,1000,1007,1010,1014,1017,1028,1031,1049,1053,1059,1065,1071,1077,1081,1088],[11,984,985],{},"The first 30 seconds of an app are the only 30 seconds most users will ever see. You can design the best tenth-screen in the world and never need to — because nobody gets past the third.",[11,987,988],{},"I've shipped two products that died at the cold start and a few that didn't. Here's what actually separated them.",[18,990,992],{"id":991},"what-cold-start-means","What \"cold start\" means",[11,994,995,996,999],{},"Not literally boot time. The ",[59,997,998],{},"product"," cold start is the stretch between \"I opened this\" and \"I understand what it does for me.\"",[11,1001,1002,1003,1006],{},"For a CLI that's the first ",[153,1004,1005],{},"--help"," output. For a web app it's the empty dashboard. For a social app it's the first scroll, with zero content.",[11,1008,1009],{},"Every one of these is a moment where the user is free to leave. They cost you nothing to build poorly, and everything to lose.",[18,1011,1013],{"id":1012},"the-anti-patterns","The anti-patterns",[11,1015,1016],{},"The obvious:",[26,1018,1019,1022,1025],{},[29,1020,1021],{},"An empty dashboard with \"get started\" and no content to start with",[29,1023,1024],{},"A signup flow that asks seven questions before showing value",[29,1026,1027],{},"A tutorial that explains the interface before the point",[11,1029,1030],{},"The less obvious:",[26,1032,1033,1036,1039],{},[29,1034,1035],{},"Defaults set to \"show nothing\" to avoid opinionated choices",[29,1037,1038],{},"Settings panels that default to locked — every feature behind a toggle",[29,1040,1041,1042,1045,1046],{},"Copy that describes ",[59,1043,1044],{},"the app"," instead of ",[59,1047,1048],{},"what the user wants",[18,1050,1052],{"id":1051},"what-works","What works",[11,1054,1055,1058],{},[32,1056,1057],{},"Pre-populate."," Seed the account with something. A sample project, a starter document, a fake teammate. Empty state is where apps die.",[11,1060,1061,1064],{},[32,1062,1063],{},"One default action."," Not three buttons of equal weight. One \"this is the thing to click.\" The others can be there; they just shouldn't compete.",[11,1066,1067,1070],{},[32,1068,1069],{},"Show the shape of the thing."," A data app should show data on day zero, even fake data. A writing app should already have something written. Never open on a void.",[11,1072,1073,1076],{},[32,1074,1075],{},"Defer the setup."," The account you set up later is twice as likely to be set up, because by then you know why.",[18,1078,1080],{"id":1079},"the-honest-version","The honest version",[11,1082,1083,1084,1087],{},"All of this boils down to one idea: ",[32,1085,1086],{},"the cold start is a writing problem, not an engineering problem",". You're convincing a stranger to spend another 30 seconds, then another minute, then an hour.",[11,1089,1090],{},"Engineers tend to reach for features. Writers reach for flow. For this specific 30 seconds, be a writer.",{"title":99,"searchDepth":100,"depth":100,"links":1092},[1093,1094,1095,1096],{"id":991,"depth":100,"text":992},{"id":1012,"depth":100,"text":1013},{"id":1051,"depth":100,"text":1052},{"id":1079,"depth":100,"text":1080},"2024-08-18",{},"\u002Fwriting\u002Fdesigning-for-the-cold-start","7 min",{"title":980,"description":985},"Most apps die at first launch. Here is the playbook I wish I had two startups ago.","writing\u002Fdesigning-for-the-cold-start","Fvk4D_LTaBOgRl_QbnWkPZLhfxTWUXBpr2KH8fUxDQ8",{"id":1106,"title":1107,"body":1108,"date":1220,"description":1112,"draft":106,"extension":107,"meta":1221,"navigation":109,"path":1222,"readTime":1223,"seo":1224,"snippet":1225,"stem":1226,"tag":115,"__hash__":1227},"writing\u002Fwriting\u002Fon-the-joy-of-small-migrations.md","On the joy of small migrations",{"type":8,"value":1109,"toc":1214},[1110,1113,1116,1120,1123,1148,1151,1155,1158,1175,1178,1182,1188,1194,1201,1205,1208,1211],[11,1111,1112],{},"The best refactor I've ever reviewed was 11 lines. It changed a function signature, updated the three call sites, bumped a comment. The PR description was one sentence. It shipped on a Tuesday and nobody noticed, which was the entire point.",[11,1114,1115],{},"Good migrations aren't loud. They're small, they fit in one head at a time, and they happen constantly.",[18,1117,1119],{"id":1118},"the-rituals-that-let-them-exist","The rituals that let them exist",[11,1121,1122],{},"Small migrations are only possible if the team has a few habits in place. None of them are clever; most of them are unfashionable.",[1124,1125,1126,1132,1138],"ol",{},[29,1127,1128,1131],{},[32,1129,1130],{},"Trunk-based development, with real discipline."," Long-lived branches accumulate divergence faster than any refactor can fight.",[29,1133,1134,1137],{},[32,1135,1136],{},"A green CI that runs in under 10 minutes."," If your test suite is 40 minutes, nobody will ever land a speculative cleanup PR.",[29,1139,1140,1143,1144,1147],{},[32,1141,1142],{},"One clear way to deprecate things."," ",[153,1145,1146],{},"@deprecated"," with a migration note, not a Slack thread.",[11,1149,1150],{},"Without these, every migration becomes a project. And projects don't land on Tuesdays.",[18,1152,1154],{"id":1153},"the-anti-pattern","The anti-pattern",[11,1156,1157],{},"The \"Big Refactor PR\" is the opposite of this. It's usually:",[26,1159,1160,1163,1166,1169,1172],{},[29,1161,1162],{},"2000+ lines",[29,1164,1165],{},"Touches every module",[29,1167,1168],{},"Described as \"a cleanup\"",[29,1170,1171],{},"Blocks other work for a week",[29,1173,1174],{},"Gets approved because nobody can review it properly",[11,1176,1177],{},"I've shipped a few of these. Every single one created bugs we didn't catch for weeks. Every single one could have been 10 smaller PRs if anyone had been patient.",[18,1179,1181],{"id":1180},"the-shift-in-thinking","The shift in thinking",[11,1183,1184,1185],{},"The mental model I had to unlearn: ",[59,1186,1187],{},"\"I'll do this properly, all at once, so we don't regress.\"",[11,1189,1190,1191],{},"The model that works: ",[59,1192,1193],{},"\"I'll do this in the smallest slice that makes sense, and accept that the codebase will look weird for a couple of weeks during the migration.\"",[11,1195,1196,1197,1200],{},"Weird-in-transition is fine. Nobody else reads the codebase during your migration; they read it ",[59,1198,1199],{},"after",". Optimize for the after.",[18,1202,1204],{"id":1203},"the-joy-part","The joy part",[11,1206,1207],{},"The title promised joy. Here it is:",[11,1209,1210],{},"A tiny migration PR is the most satisfying thing to ship. It's small enough to fully understand. It's easy to review. It doesn't break anything. It leaves the codebase a little better than you found it. You can do it on a Tuesday.",[11,1212,1213],{},"Five of those in a year adds up to a codebase that aged well. One \"great refactor\" adds up to a war story.",{"title":99,"searchDepth":100,"depth":100,"links":1215},[1216,1217,1218,1219],{"id":1118,"depth":100,"text":1119},{"id":1153,"depth":100,"text":1154},{"id":1180,"depth":100,"text":1181},{"id":1203,"depth":100,"text":1204},"2024-07-01",{},"\u002Fwriting\u002Fon-the-joy-of-small-migrations","4 min",{"title":1107,"description":1112},"Why the best refactors fit in a single PR — and the team rituals that make them possible.","writing\u002Fon-the-joy-of-small-migrations","HKThQvPjYrfQBbc9UO8ognxC-6FK20qUv40-eSoPsjw",{"id":1229,"title":1230,"body":1231,"date":1587,"description":1235,"draft":106,"extension":107,"meta":1588,"navigation":109,"path":1589,"readTime":1590,"seo":1591,"snippet":1592,"stem":1593,"tag":554,"__hash__":1594},"writing\u002Fwriting\u002Fpostgres-as-the-entire-backend.md","Postgres as the entire backend",{"type":8,"value":1232,"toc":1581},[1233,1236,1239,1243,1246,1300,1303,1307,1314,1318,1321,1545,1554,1558,1561,1575,1578],[11,1234,1235],{},"The trend this year is to add more services. Auth service, queue service, analytics service, feature-flag service. Each of these is a reasonable idea in isolation, but together they add up to an ops bill and a mental model that barely fits in one head.",[11,1237,1238],{},"Postgres can do 80% of this. It's worth saying that out loud before reaching for the next service.",[18,1240,1242],{"id":1241},"the-features-that-replace-services","The features that replace services",[11,1244,1245],{},"Most teams know Postgres as \"the database.\" It is also:",[26,1247,1248,1262,1276,1285,1291],{},[29,1249,1250,1253,1254,1257,1258,1261],{},[32,1251,1252],{},"An auth layer",": row-level security (",[153,1255,1256],{},"RLS",") policies are first-class. Define them once, enforce them in every query, stop writing ",[153,1259,1260],{},"WHERE user_id = ?"," in application code.",[29,1263,1264,1267,1268,1271,1272,1275],{},[32,1265,1266],{},"A message bus",": ",[153,1269,1270],{},"LISTEN"," \u002F ",[153,1273,1274],{},"NOTIFY"," gets you pub\u002Fsub without Redis. Not replayable, not durable, but good enough for \"tell the cron worker something happened.\"",[29,1277,1278,1267,1281,1284],{},[32,1279,1280],{},"A queue",[153,1282,1283],{},"FOR UPDATE SKIP LOCKED"," turns a regular table into a work queue. I've had one running at hundreds of jobs per second for two years, untouched.",[29,1286,1287,1290],{},[32,1288,1289],{},"A read cache",": materialised views refresh on a schedule. The refresh has a cost but so does every cache invalidation strategy you were about to invent.",[29,1292,1293,1267,1296,1299],{},[32,1294,1295],{},"An analytics store",[153,1297,1298],{},"generate_series",", window functions, lateral joins. Enough to power most dashboards before you need Clickhouse.",[11,1301,1302],{},"None of these are exotic. All of them ship in stock Postgres.",[18,1304,1306],{"id":1305},"the-one-bad-fit","The one bad fit",[11,1308,1309,1310,1313],{},"The one thing Postgres is genuinely bad at is ",[32,1311,1312],{},"event sourcing"," at scale. If you need millions of events per second, write-amplify for analytics, and time-travel reads — go somewhere else. But that's less than 5% of apps I've seen.",[18,1315,1317],{"id":1316},"a-concrete-example","A concrete example",[11,1319,1320],{},"Here's a queue in Postgres. 15 lines:",[147,1322,1326],{"className":1323,"code":1324,"language":1325,"meta":99,"style":99},"language-sql shiki shiki-themes material-theme-lighter github-dark-dimmed github-dark-dimmed","CREATE TABLE jobs (\n  id bigserial PRIMARY KEY,\n  kind text NOT NULL,\n  payload jsonb NOT NULL,\n  run_after timestamptz DEFAULT now(),\n  attempts int DEFAULT 0\n);\n\n-- worker loop:\nWITH next_job AS (\n  SELECT id FROM jobs\n  WHERE run_after \u003C= now()\n  ORDER BY run_after\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nDELETE FROM jobs WHERE id IN (SELECT id FROM next_job) RETURNING *;\n","sql",[153,1327,1328,1343,1357,1369,1379,1398,1411,1416,1421,1426,1439,1453,1469,1478,1487,1502,1507],{"__ignoreMap":99},[156,1329,1330,1334,1337,1340],{"class":158,"line":159},[156,1331,1333],{"class":1332},"s3eKB","CREATE",[156,1335,1336],{"class":1332}," TABLE",[156,1338,1339],{"class":222}," jobs",[156,1341,1342],{"class":239}," (\n",[156,1344,1345,1348,1351,1354],{"class":158,"line":100},[156,1346,1347],{"class":239},"  id ",[156,1349,1350],{"class":162},"bigserial",[156,1352,1353],{"class":162}," PRIMARY KEY",[156,1355,1356],{"class":239},",\n",[156,1358,1359,1362,1364,1367],{"class":158,"line":277},[156,1360,1361],{"class":239},"  kind ",[156,1363,331],{"class":162},[156,1365,1366],{"class":1332}," NOT NULL",[156,1368,1356],{"class":239},[156,1370,1371,1374,1377],{"class":158,"line":305},[156,1372,1373],{"class":239},"  payload jsonb ",[156,1375,1376],{"class":1332},"NOT NULL",[156,1378,1356],{"class":239},[156,1380,1381,1384,1387,1390,1393,1396],{"class":158,"line":339},[156,1382,1383],{"class":239},"  run_after ",[156,1385,1386],{"class":162},"timestamptz",[156,1388,1389],{"class":162}," DEFAULT",[156,1391,1392],{"class":1332}," now",[156,1394,1395],{"class":177},"()",[156,1397,1356],{"class":239},[156,1399,1400,1403,1406,1408],{"class":158,"line":373},[156,1401,1402],{"class":239},"  attempts ",[156,1404,1405],{"class":162},"int",[156,1407,1389],{"class":162},[156,1409,1410],{"class":416}," 0\n",[156,1412,1413],{"class":158,"line":431},[156,1414,1415],{"class":239},");\n",[156,1417,1418],{"class":158,"line":451},[156,1419,1420],{"emptyLinePlaceholder":109},"\n",[156,1422,1423],{"class":158,"line":457},[156,1424,1425],{"class":205},"-- worker loop:\n",[156,1427,1428,1431,1434,1437],{"class":158,"line":463},[156,1429,1430],{"class":1332},"WITH",[156,1432,1433],{"class":239}," next_job ",[156,1435,1436],{"class":1332},"AS",[156,1438,1342],{"class":239},[156,1440,1441,1444,1447,1450],{"class":158,"line":472},[156,1442,1443],{"class":1332},"  SELECT",[156,1445,1446],{"class":239}," id ",[156,1448,1449],{"class":1332},"FROM",[156,1451,1452],{"class":239}," jobs\n",[156,1454,1456,1459,1462,1465,1467],{"class":158,"line":1455},12,[156,1457,1458],{"class":1332},"  WHERE",[156,1460,1461],{"class":239}," run_after ",[156,1463,1464],{"class":170},"\u003C=",[156,1466,1392],{"class":1332},[156,1468,274],{"class":177},[156,1470,1472,1475],{"class":158,"line":1471},13,[156,1473,1474],{"class":1332},"  ORDER BY",[156,1476,1477],{"class":239}," run_after\n",[156,1479,1481,1484],{"class":158,"line":1480},14,[156,1482,1483],{"class":1332},"  LIMIT",[156,1485,1486],{"class":416}," 1\n",[156,1488,1490,1493,1496,1499],{"class":158,"line":1489},15,[156,1491,1492],{"class":1332},"  FOR",[156,1494,1495],{"class":1332}," UPDATE",[156,1497,1498],{"class":1332}," SKIP",[156,1500,1501],{"class":239}," LOCKED\n",[156,1503,1505],{"class":158,"line":1504},16,[156,1506,428],{"class":239},[156,1508,1510,1513,1516,1519,1522,1524,1527,1529,1532,1534,1536,1539,1542],{"class":158,"line":1509},17,[156,1511,1512],{"class":1332},"DELETE",[156,1514,1515],{"class":1332}," FROM",[156,1517,1518],{"class":239}," jobs ",[156,1520,1521],{"class":1332},"WHERE",[156,1523,1446],{"class":239},[156,1525,1526],{"class":1332},"IN",[156,1528,284],{"class":239},[156,1530,1531],{"class":1332},"SELECT",[156,1533,1446],{"class":239},[156,1535,1449],{"class":1332},[156,1537,1538],{"class":239}," next_job) RETURNING ",[156,1540,1541],{"class":170},"*",[156,1543,1544],{"class":239},";\n",[11,1546,1547,1548,1045,1551,1553],{},"That's a work queue. Many workers, no double-processing, no extra infra. Add a retry policy with ",[153,1549,1550],{},"UPDATE",[153,1552,1512],{}," and you're done.",[18,1555,1557],{"id":1556},"the-trade-off","The trade-off",[11,1559,1560],{},"The price is:",[26,1562,1563,1569],{},[29,1564,1565,1568],{},[32,1566,1567],{},"Operational concentration."," If Postgres dies, your whole world dies. With separate services, each outage is smaller.",[29,1570,1571,1574],{},[32,1572,1573],{},"Single vendor lock-in."," Moving off Postgres one feature at a time is harder than moving off a dedicated service.",[11,1576,1577],{},"For most small-to-medium apps — including every side project I've shipped — the upside outweighs the cost by a large margin. Fewer moving parts is a real architectural virtue; we just stopped valuing it.",[538,1579,1580],{},"html pre.shiki code .s3eKB, html code.shiki .s3eKB{--shiki-light:#F76D47;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .syDMH, html code.shiki .syDMH{--shiki-light:#6182B8;--shiki-default:#DCBDFB;--shiki-dark:#DCBDFB}html pre.shiki code .sw5_h, html code.shiki .sw5_h{--shiki-light:#90A4AE;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sdLfj, html code.shiki .sdLfj{--shiki-light:#9C3EDA;--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sDCYr, html code.shiki .sDCYr{--shiki-light:#39ADB5;--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sqQCU, html code.shiki .sqQCU{--shiki-light:#F76D47;--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .svbSo, html code.shiki .svbSo{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#768390;--shiki-default-font-style:inherit;--shiki-dark:#768390;--shiki-dark-font-style:inherit}html pre.shiki code .sHeb4, html code.shiki .sHeb4{--shiki-light:#39ADB5;--shiki-default:#F47067;--shiki-dark:#F47067}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":99,"searchDepth":100,"depth":100,"links":1582},[1583,1584,1585,1586],{"id":1241,"depth":100,"text":1242},{"id":1305,"depth":100,"text":1306},{"id":1316,"depth":100,"text":1317},{"id":1556,"depth":100,"text":1557},"2024-05-12",{},"\u002Fwriting\u002Fpostgres-as-the-entire-backend","10 min",{"title":1230,"description":1235},"Row-level security, listen\u002Fnotify, materialized views, and other reasons to delete half your stack.","writing\u002Fpostgres-as-the-entire-backend","e8H6crh36O90p7MBURlQj7bxW-0T2cWPOQvVmjjN_-E",{"id":1596,"title":1597,"body":1598,"date":1728,"description":1602,"draft":106,"extension":107,"meta":1729,"navigation":109,"path":1730,"readTime":111,"seo":1731,"snippet":1732,"stem":1733,"tag":673,"__hash__":1734},"writing\u002Fwriting\u002Ffive-years-of-self-hosting.md","Five years of self-hosting",{"type":8,"value":1599,"toc":1721},[1600,1603,1606,1610,1615,1618,1622,1625,1651,1654,1658,1661,1684,1688,1691,1711,1715,1718],[11,1601,1602],{},"I've been running my own small fleet of services for five years now: a couple of personal apps, a few side projects, a mail relay, a git mirror, a home dashboard. Some on a single VPS, some on a Raspberry Pi, most running longer than I expected.",[11,1604,1605],{},"Here's what I've learned, in descending order of usefulness.",[18,1607,1609],{"id":1608},"the-one-rule","The one rule",[11,1611,1612],{},[32,1613,1614],{},"Write down how to rebuild everything on a fresh box.",[11,1616,1617],{},"Not in your head. Not in your memory. In a file, committed to git, updated when the process changes. Everything else I've learned is a corollary of this.",[18,1619,1621],{"id":1620},"what-breaks","What breaks",[11,1623,1624],{},"Things fail in predictable ways:",[26,1626,1627,1633,1639,1645],{},[29,1628,1629,1632],{},[32,1630,1631],{},"Disks fill up"," because log rotation is something you turned off and forgot",[29,1634,1635,1638],{},[32,1636,1637],{},"Certificates expire"," because Let's Encrypt renewal was running in a cron job on a server you decommissioned",[29,1640,1641,1644],{},[32,1642,1643],{},"Backups are never tested"," until the day you find out they've been empty for six months",[29,1646,1647,1650],{},[32,1648,1649],{},"OS upgrades break things"," because the PHP version bumped and your self-hosted CMS wasn't ready",[11,1652,1653],{},"I've done every one of these. The pattern: none of them were hard problems. All of them were ignored for too long.",[18,1655,1657],{"id":1656},"the-disciplines-that-pay-off","The disciplines that pay off",[11,1659,1660],{},"The three things that saved me most pain:",[26,1662,1663,1669,1675],{},[29,1664,1665,1668],{},[32,1666,1667],{},"Uptime monitoring you actually see."," I use a push notification when anything goes red. Dashboards you have to log into are read when everything is already on fire.",[29,1670,1671,1674],{},[32,1672,1673],{},"Unattended upgrades set to security-only."," Auto-apply kernel and critical patches. Skip the rest. This is the single highest-ROI line of config you can write.",[29,1676,1677,1683],{},[32,1678,1679,1680,328],{},"A nightly backup that writes to ",[59,1681,1682],{},"somewhere else"," S3, a B2 bucket, a friend's NAS. Same machine isn't backup; it's a RAID you can delete by accident.",[18,1685,1687],{"id":1686},"what-ive-stopped-self-hosting","What I've stopped self-hosting",[11,1689,1690],{},"After five years, the things I've handed back to hosted services:",[26,1692,1693,1699,1705],{},[29,1694,1695,1698],{},[32,1696,1697],{},"Email."," Reputation is harder than SMTP. Fastmail is worth every rupee.",[29,1700,1701,1704],{},[32,1702,1703],{},"DNS."," Cloudflare's free tier is operationally invisible and I had an incident once where my DNS was my actual website.",[29,1706,1707,1710],{},[32,1708,1709],{},"Continuous integration."," GitHub Actions is fine. My CI server kept running out of disk.",[18,1712,1714],{"id":1713},"whats-still-on-the-fleet","What's still on the fleet",[11,1716,1717],{},"Analytics, a small git frontend, a bookmarks app, a couple of internal tools. All boring, all Postgres-backed, all easy to move.",[11,1719,1720],{},"Self-hosting is less about stopping paying for things and more about knowing where things live when they break. It's a muscle, and five years in, it's the most useful one I've built.",{"title":99,"searchDepth":100,"depth":100,"links":1722},[1723,1724,1725,1726,1727],{"id":1608,"depth":100,"text":1609},{"id":1620,"depth":100,"text":1621},{"id":1656,"depth":100,"text":1657},{"id":1686,"depth":100,"text":1687},{"id":1713,"depth":100,"text":1714},"2024-03-08",{},"\u002Fwriting\u002Ffive-years-of-self-hosting",{"title":1597,"description":1602},"A personal retrospective. Mostly what broke, occasionally what worked.","writing\u002Ffive-years-of-self-hosting","2RQ8soDgrIKsP1hDZH3XItUTi9ULe3YJSsl0wEdFyvI",{"id":1736,"title":1737,"body":1738,"date":1855,"description":1742,"draft":106,"extension":107,"meta":1856,"navigation":109,"path":1857,"readTime":830,"seo":1858,"snippet":1859,"stem":1860,"tag":1861,"__hash__":1862},"writing\u002Fwriting\u002Fthe-case-for-boring-frontends.md","The case for boring frontends",{"type":8,"value":1739,"toc":1849},[1740,1743,1746,1750,1757,1760,1778,1781,1785,1788,1799,1802,1817,1821,1824,1838,1841,1843,1846],[11,1741,1742],{},"Every few months someone reminds the community that HTML is enough. Every few months everyone nods, keeps writing React apps, and goes back to Redux.",[11,1744,1745],{},"I've been on both sides of this. This essay is why I'm mostly off the bus.",[18,1747,1749],{"id":1748},"the-argument-compressed","The argument, compressed",[11,1751,1752,1753,1756],{},"Most \"apps\" are not apps. They're documents with a few forms. Treating a docs site, a blog, an admin panel, or a marketing page as an SPA is choosing the wrong tool for the job the same way treating a screwdriver as a hammer is choosing the wrong tool. You ",[59,1754,1755],{},"can"," hit the nail; it just takes longer and bends the nail.",[11,1758,1759],{},"A boring frontend means:",[26,1761,1762,1765,1768,1775],{},[29,1763,1764],{},"HTML rendered on the server — by whatever framework",[29,1766,1767],{},"CSS that's mostly static",[29,1769,1770,1771,1774],{},"A sprinkle of JS where interactivity matters (",[59,1772,1773],{},"not"," everywhere)",[29,1776,1777],{},"No client-side routing unless the UX actually demands it",[11,1779,1780],{},"You get a site that loads instantly, ranks on SEO without effort, works with JS disabled, and can be maintained by a single person on weekends. None of which is true of a typical SPA.",[18,1782,1784],{"id":1783},"what-i-used-to-think","What I used to think",[11,1786,1787],{},"I used to think the SPA-everywhere gradient was justified because:",[26,1789,1790,1793,1796],{},[29,1791,1792],{},"Transitions feel nicer",[29,1794,1795],{},"State is easier to manage in one runtime",[29,1797,1798],{},"APIs stay clean because the frontend is decoupled",[11,1800,1801],{},"Here's what I actually observed after a few years:",[26,1803,1804,1807,1814],{},[29,1805,1806],{},"Transitions mostly just delay the page I wanted",[29,1808,1809,1810,1813],{},"State is ",[59,1811,1812],{},"harder"," to manage in one runtime, because you're now the source of truth for a thing that used to be the server's job",[29,1815,1816],{},"APIs get messier, not cleaner — they become views of the frontend, not a stable interface",[18,1818,1820],{"id":1819},"when-i-still-reach-for-an-spa","When I still reach for an SPA",[11,1822,1823],{},"There are real cases:",[26,1825,1826,1829,1832,1835],{},[29,1827,1828],{},"Real-time collaboration (figma, multiplayer tools)",[29,1830,1831],{},"Dense interactive UIs (maps, editors, whiteboards)",[29,1833,1834],{},"Offline-first apps",[29,1836,1837],{},"Anything with a persistent UI state across routes",[11,1839,1840],{},"If your app is on that list, build it as an SPA. If it's not, consider that you're paying the SPA tax for an experience the user wasn't asking for.",[18,1842,1080],{"id":1079},[11,1844,1845],{},"The boring stack lets me ship on weekends. That's the actual test for me. If I can't build and deploy a small idea in a Saturday morning, the stack is too heavy.",[11,1847,1848],{},"Five years in on this approach, the gap between \"I thought of a thing\" and \"it's live\" has gone from days to hours. That gap is where most good ideas die. Anything that shortens it is, in my view, the actual job.",{"title":99,"searchDepth":100,"depth":100,"links":1850},[1851,1852,1853,1854],{"id":1748,"depth":100,"text":1749},{"id":1783,"depth":100,"text":1784},{"id":1819,"depth":100,"text":1820},{"id":1079,"depth":100,"text":1080},"2024-01-29",{},"\u002Fwriting\u002Fthe-case-for-boring-frontends",{"title":1737,"description":1742},"Server-rendered HTML, a sprinkle of JS, and the boring stack that lets me ship on weekends.","writing\u002Fthe-case-for-boring-frontends","opinion","HNEcIfeWyffvvkauafA5HsiFuNfeHD1jJHMKGVrp9JU",1776601654825]