[{"data":1,"prerenderedAt":152},["ShallowReactive",2],{"journal-\u002Fjournal\u002Fthe-race-condition-in-every-search-box":3},{"id":4,"title":5,"body":6,"date":138,"description":139,"draft":140,"extension":141,"meta":142,"navigation":143,"path":144,"seo":145,"stem":146,"tags":147,"__hash__":151},"journal\u002Fjournal\u002Fthe-race-condition-in-every-search-box.md","The Race Condition Hiding in Every Search Box",{"type":7,"value":8,"toc":129},"minimark",[9,13,16,21,48,51,59,71,74,78,81,84,87,91,94,97,100,103,107,110,113,116,120,123,126],[10,11,12],"p",{},"QA filed a bug I couldn't reproduce on demand. Sometimes the admin search shows results that don't match what's in the box. Type a name, get a list that clearly ignores it. Retype the same thing and it's fine.",[10,14,15],{},"Bugs like that are usually a race. What makes this one worth writing up is that it isn't a search bug at all, and the same shape turns up in almost every search-as-you-type feature I've worked on.",[17,18,20],"h2",{"id":19},"the-counterintuitive-part","The counterintuitive part",[10,22,23,24,28,29,32,33,32,36,32,39,32,42,32,45,47],{},"Search-as-you-type fires a request per keystroke. Type ",[25,26,27],"code",{},"hendri"," and six can be in flight: ",[25,30,31],{},"h",", ",[25,34,35],{},"he",[25,37,38],{},"hen",[25,40,41],{},"hend",[25,43,44],{},"hendr",[25,46,27],{},".",[10,49,50],{},"The instinct is to assume they come back in order. They don't, and here's why: the broadest query is the slowest one.",[10,52,53,55,56,58],{},[25,54,31],{}," matches an enormous slice of the collection. The database scans a lot, serializes a lot, takes its time. ",[25,57,27],{}," matches a handful of rows and returns almost immediately. In a production-like harness I measured the two ends at roughly 800ms and 60ms.",[10,60,61,62,64,65,67,68,70],{},"So the correct fast answer for ",[25,63,27],{}," lands first and paints. Then, most of a second later, the stale answer for ",[25,66,31],{}," arrives and overwrites it. The user sees a list with nothing to do with what they typed, and the box has said ",[25,69,27],{}," the whole time.",[10,72,73],{},"Every request succeeded. Every response was correct for the query that produced it. Nothing in the error logs.",[17,75,77],{"id":76},"debouncing-doesnt-fix-it","Debouncing doesn't fix it",[10,79,80],{},"The usual answer is add a debounce. It helps, and it treats the wrong thing. Debouncing changes how many requests you fire. It does nothing about what order the answers arrive in. Cut six requests to two and you still have the race, just rarer, which is worse, because now it survives testing and reaches users.",[10,82,83],{},"Ours was configured badly on top of that. A leading-edge throttle, firing immediately on the first keystroke and then continuing as you type. That's backwards for a search box. The first keystroke is the least specific thing a user will ever type and the most expensive query you can run. We switched to a trailing debounce so the request goes out once typing pauses.",[10,85,86],{},"Real improvement. Still not a fix.",[17,88,90],{"id":89},"the-fix","The fix",[10,92,93],{},"Make the newer request cancel the older one. When a request supersedes a previous one, abort the previous one at the HTTP layer so its response can never come back and paint. This went in at the axios adapter alongside deduplication of identical in-flight reads, so it applied everywhere instead of being reimplemented per screen. It mattered well beyond search, since every filtered table has the same pattern.",[10,95,96],{},"Two things I got wrong.",[10,98,99],{},"I keyed the cancellation guard off the HTTP method. Never cancel writes, which is sensible, because aborting a POST mid-flight leaves you unsure whether the server committed it. But some of our reads are POSTs, since the filter payload is too complex for a query string. Guarding on method alone quietly excluded every one of them, so the screens with the heaviest filters kept racing. The guard has to key on what the request is, not which verb it happens to use.",[10,101,102],{},"Then I forgot the server doesn't know. Cancelling on the client only stops you listening. The server cheerfully finishes an expensive query for a browser that hung up, which is the worst of both worlds. The user waits on the request they care about while the database works on one nobody will read. So the server needed to check for client disconnect and stop. That depends on the reverse proxy propagating the abort, an nginx default we were relying on without knowing it, which I wrote into the ops docs. An undocumented dependency is a future outage.",[17,104,106],{"id":105},"then-it-broke","Then it broke",[10,108,109],{},"The change shipped and took down dashboard access on staging. Not search. The whole dashboard.",[10,111,112],{},"The cancellation logic reached for the router in a way that worked under one framework version and returned undefined under the bridge we were mid-migration on. Reverted the same day, fixed the router access properly, re-landed it.",[10,114,115],{},"I'd rather write that down than leave it out. The interesting part isn't that I found a race condition. It's that fixing a subtle correctness bug introduced a blunt availability one, and what saved us was that reverting was cheap and boring.",[17,117,119],{"id":118},"two-habits","Two habits",[10,121,122],{},"When a bug is intermittent and correlates with speed, suspect ordering before correctness. Everything succeeding is a clue, not a dead end.",[10,124,125],{},"And fix it at the layer where it can't be forgotten. Patching the search screen would have left every filtered table in the product carrying the same bug, waiting for someone to notice.",[10,127,128],{},"The user-facing version of all this is much simpler. When you type, you should see what you typed. It's worth a surprising amount of plumbing to keep that promise.",{"title":130,"searchDepth":131,"depth":131,"links":132},"",2,[133,134,135,136,137],{"id":19,"depth":131,"text":20},{"id":76,"depth":131,"text":77},{"id":89,"depth":131,"text":90},{"id":105,"depth":131,"text":106},{"id":118,"depth":131,"text":119},"2026-08-14","A search-as-you-type bug where the results didn't match what you typed. The cause wasn't search quality. It was that the slowest request always carried the least useful answer.",false,"md",{},true,"\u002Fjournal\u002Fthe-race-condition-in-every-search-box",{"title":5,"description":139},"journal\u002Fthe-race-condition-in-every-search-box",[148,149,150],"engineering","performance","debugging","qblSGoDK4WpgAytZ7s9aJASFIdHaeWF1FIF6IjE_qdg",1787915616666]