[{"data":1,"prerenderedAt":1503},["ShallowReactive",2],{"blog-bcrypt-login-bottleneck-kubernetes":3},{"id":4,"title":5,"author":6,"body":7,"date":1479,"description":1480,"draft":1481,"extension":1482,"image":1483,"keywords":1484,"meta":1492,"modified":1479,"navigation":719,"path":1493,"seo":1494,"stem":1495,"tags":1496,"__hash__":1502},"blog\u002Fblog\u002Fbcrypt-login-bottleneck-kubernetes.md","100 Users Log In at Once and Your API Dies — The bcrypt Bottleneck Nobody Warns You About","haile37",{"type":8,"value":9,"toc":1461},"minimark",[10,14,17,30,41,46,49,80,87,90,93,97,100,103,134,137,140,158,169,173,176,190,196,202,246,253,259,263,266,298,301,343,346,349,354,361,366,373,378,385,388,392,399,433,440,444,447,453,465,471,477,484,488,499,502,507,510,634,637,641,644,652,655,844,851,992,998,1115,1129,1133,1139,1199,1206,1212,1216,1219,1290,1293,1297,1300,1372,1375,1379,1389,1398,1404,1414,1423,1427,1433,1436,1440,1457],[11,12,13],"p",{},"We shipped the login endpoint, ran it through QA, and called it done. Single-user tests looked fine. bcrypt with 10 rounds felt reasonable — secure, standard, what every tutorial recommends.",[11,15,16],{},"Then we pointed k6 at it with 100 virtual users and watched the whole authentication path fall apart.",[11,18,19,20,29],{},"Requests that normally returned in 200ms started timing out at 30 seconds. Error rates climbed. Pod CPU graphs looked like a heartbeat monitor having a bad day. And the strangest part: when we added logging around the password check alone, ",[21,22,23,24,28],"strong",{},"a single ",[25,26,27],"code",{},"bcrypt.compare"," call was taking up to 6 seconds"," under load — not because bcrypt rounds were wrong, but because the requests were queuing behind each other like cars at a toll booth with one open lane.",[11,31,32,33,36,37,40],{},"This is the story of that bottleneck, why ",[25,34,35],{},"UV_THREADPOOL_SIZE"," wasn't the whole answer, and how we fixed it by moving password verification into a ",[21,38,39],{},"NestJS native script"," — a separate entry point in the same codebase, no new microservice required.",[42,43,45],"h2",{"id":44},"the-setup","The setup",[11,47,48],{},"Our stack was straightforward:",[50,51,52,62,68,74],"ul",{},[53,54,55,58,59],"li",{},[21,56,57],{},"NestJS"," API behind an ingress controller on ",[21,60,61],{},"Kubernetes",[53,63,64,67],{},[21,65,66],{},"PostgreSQL"," for user records",[53,69,70,73],{},[21,71,72],{},"bcrypt"," (cost factor 10) for password hashing at registration and comparison at login",[53,75,76,79],{},[21,77,78],{},"k6"," for load testing before a marketing launch that expected a traffic spike",[11,81,82,83,86],{},"The login flow was equally standard: look up the user by email, fetch the stored hash, run ",[25,84,85],{},"bcrypt.compare(plainPassword, storedHash)",", issue a JWT if it matched.",[11,88,89],{},"In development, this felt fast. On staging with one user clicking login, p95 latency was under 300ms. We had two replicas, horizontal pod autoscaling configured, and reasonable CPU limits — 500m request, 1000m limit per pod.",[11,91,92],{},"Confident, we ran the load test.",[42,94,96],{"id":95},"what-100-vus-actually-looked-like","What 100 VUs actually looked like",[11,98,99],{},"The k6 script was simple: 100 virtual users, ramp up over 30 seconds, each executing a login against a pool of test accounts with known passwords.",[11,101,102],{},"Within two minutes:",[50,104,105,111,117,123],{},[53,106,107,110],{},[21,108,109],{},"p95 response time"," exceeded 15 seconds",[53,112,113,116],{},[21,114,115],{},"p99"," hit the 30-second client timeout",[53,118,119,122],{},[21,120,121],{},"HTTP 504"," errors appeared at the ingress layer",[53,124,125,126,130,131],{},"Application logs showed login handlers ",[127,128,129],"em",{},"starting"," but not ",[127,132,133],{},"finishing",[11,135,136],{},"CPU on both pods pegged near their limits. Memory was fine. Database connection pool had headroom. The bottleneck wasn't I\u002FO — it was something blocking inside the Node.js process itself.",[11,138,139],{},"We stripped the login handler down to isolate the cost:",[141,142,143,146,149],"ol",{},[53,144,145],{},"DB lookup only → fast, even under load",[53,147,148],{},"DB lookup + JWT signing → still fine",[53,150,151,152,154,155],{},"DB lookup + ",[25,153,27],{}," → ",[21,156,157],{},"everything collapsed",[11,159,160,161,164,165,168],{},"That's when we measured it: under 100 concurrent login attempts, individual compare operations that took ~80ms in isolation were taking ",[21,162,163],{},"4–6 seconds"," wall-clock time. Not because bcrypt got slower — because they were ",[21,166,167],{},"waiting in a queue",".",[42,170,172],{"id":171},"why-bcrypt-chokes-nodejs-under-concurrency","Why bcrypt chokes Node.js under concurrency",[11,174,175],{},"bcrypt is deliberately slow. That's the feature. Each compare is CPU-intensive work designed to make brute-force attacks expensive.",[11,177,178,179,181,182,185,186,189],{},"In Node.js, the ",[25,180,72],{}," npm package offloads this work to ",[21,183,184],{},"libuv's thread pool"," so the main event loop can keep handling other requests — in theory. In practice, that thread pool is ",[21,187,188],{},"shared globally"," across your entire process for all async file I\u002FO, DNS lookups, and native crypto operations that use it.",[11,191,192,193],{},"The default pool size? ",[21,194,195],{},"4 threads.",[11,197,198,199,201],{},"So when 100 login requests arrive and each one calls ",[25,200,27],{},", you get something like this:",[203,204,205,218],"table",{},[206,207,208],"thead",{},[209,210,211,215],"tr",{},[212,213,214],"th",{},"Concurrent compares waiting",[212,216,217],{},"Effective behaviour",[219,220,221,230,238],"tbody",{},[209,222,223,227],{},[224,225,226],"td",{},"1–4",[224,228,229],{},"Compares run in parallel, latency stays low",[209,231,232,235],{},[224,233,234],{},"5–20",[224,236,237],{},"Requests queue, latency grows linearly",[209,239,240,243],{},[224,241,242],{},"50–100",[224,244,245],{},"Queue depth explodes, timeouts everywhere",[11,247,248,249,252],{},"Do the math: if each compare takes 80ms and you have 4 threads, throughput is roughly 50 compares\u002Fsecond. At 100 simultaneous logins, the last request in a batch might wait ",[21,250,251],{},"2 seconds"," just in queue time — before you account for CPU throttling, GC pauses, or other pool consumers.",[11,254,255,256,168],{},"That 6-second compare we measured wasn't bcrypt running for 6 seconds. It was ",[21,257,258],{},"80ms of work + 5+ seconds of waiting",[42,260,262],{"id":261},"we-tried-raising-uv_threadpool_size","We tried raising UV_THREADPOOL_SIZE",[11,264,265],{},"The first fix everyone suggests — including Stack Overflow, including me before this incident — is bumping the thread pool:",[267,268,273],"pre",{"className":269,"code":270,"language":271,"meta":272,"style":272},"language-bash shiki shiki-themes github-dark","UV_THREADPOOL_SIZE=128 node dist\u002Fmain.js\n","bash","",[25,274,275],{"__ignoreMap":272},[276,277,280,283,287,291,295],"span",{"class":278,"line":279},"line",1,[276,281,35],{"class":282},"s95oV",[276,284,286],{"class":285},"snl16","=",[276,288,290],{"class":289},"sU2Wk","128",[276,292,294],{"class":293},"svObZ"," node",[276,296,297],{"class":289}," dist\u002Fmain.js\n",[11,299,300],{},"We added it to our Kubernetes deployment manifest:",[267,302,306],{"className":303,"code":304,"language":305,"meta":272,"style":272},"language-yaml shiki shiki-themes github-dark","env:\n  - name: UV_THREADPOOL_SIZE\n    value: \"128\"\n","yaml",[25,307,308,317,332],{"__ignoreMap":272},[276,309,310,314],{"class":278,"line":279},[276,311,313],{"class":312},"s4JwU","env",[276,315,316],{"class":282},":\n",[276,318,320,323,326,329],{"class":278,"line":319},2,[276,321,322],{"class":282},"  - ",[276,324,325],{"class":312},"name",[276,327,328],{"class":282},": ",[276,330,331],{"class":289},"UV_THREADPOOL_SIZE\n",[276,333,335,338,340],{"class":278,"line":334},3,[276,336,337],{"class":312},"    value",[276,339,328],{"class":282},[276,341,342],{"class":289},"\"128\"\n",[11,344,345],{},"It helped. Timeouts dropped. p95 went from 15s to around 4s. Better, but still unacceptable for a login endpoint, and the numbers didn't match what we expected.",[11,347,348],{},"Three problems remained:",[11,350,351],{},[21,352,353],{},"1. CPU limits on Kubernetes",[11,355,356,357,360],{},"More thread pool threads don't create more CPU cores. Our pods were capped at 1 core. Throwing 128 threads at 1 core mostly means ",[21,358,359],{},"128 threads competing for the same CPU",", with context-switch overhead on top. Under load, the kernel scheduler became part of the bottleneck.",[11,362,363],{},[21,364,365],{},"2. Every pod has its own pool",[11,367,368,369,372],{},"With 2 replicas, we didn't have a pool of 256 threads — we had ",[21,370,371],{},"two independent pools of 128",", each attached to a pod receiving roughly half the traffic. Scaling horizontally didn't fix the per-process concurrency math.",[11,374,375],{},[21,376,377],{},"3. The HTTP server still shared the pool",[11,379,380,381,384],{},"Our NestJS process wasn't just verifying passwords. It was also serving health checks, handling token refresh endpoints, writing audit logs, and running background cron tasks via ",[25,382,383],{},"@nestjs\u002Fschedule",". All of them competed for the same libuv thread pool. Login traffic could starve everything else — or the reverse.",[11,386,387],{},"We had improved the symptom without fixing the architecture.",[42,389,391],{"id":390},"why-this-hurts-more-on-kubernetes-than-on-a-laptop","Why this hurts more on Kubernetes than on a laptop",[11,393,394,395,398],{},"On a local machine with 8 cores and no CPU limit, ",[25,396,397],{},"UV_THREADPOOL_SIZE=16"," often \"just works\" for moderate load tests. Kubernetes adds constraints that make bcrypt's behaviour much worse:",[50,400,401,407,413,423],{},[53,402,403,406],{},[21,404,405],{},"CPU limits"," throttle your process mid-compute, stretching compare times unpredictably",[53,408,409,412],{},[21,410,411],{},"Multiple replicas"," split traffic but don't coordinate CPU-heavy work",[53,414,415,418,419,422],{},[21,416,417],{},"Liveness probes"," still hit ",[25,420,421],{},"\u002Fhealth"," while login hammers the thread pool — we saw health check latency spike during load tests, which almost triggered pod restarts",[53,424,425,428,429,432],{},[21,426,427],{},"Autoscaling on CPU"," kicked in, added a third pod, and briefly made things ",[127,430,431],{},"worse"," as new pods cold-started during the traffic peak",[11,434,435,436,439],{},"The load test that passed on a developer's M1 Mac failed miserably against production-like k8s limits. That gap is worth closing ",[21,437,438],{},"before"," you promise a launch date.",[42,441,443],{"id":442},"what-didnt-work-and-why","What didn't work (and why)",[11,445,446],{},"We tried several intermediate fixes. Each one taught us something:",[11,448,449,452],{},[21,450,451],{},"Lowering bcrypt rounds (12 → 10 → 8)"," — We were already at 10. Dropping to 8 shaved maybe 30% off compare time but didn't solve queueing at 100 VUs. Also a security regression we weren't willing to ship.",[11,454,455,464],{},[21,456,457,458,460,461],{},"Switching to ",[25,459,27],{}," vs ",[25,462,463],{},"compareSync"," — We were already on the async variant. Both use the thread pool. No meaningful difference under this load pattern.",[11,466,467,470],{},[21,468,469],{},"Rate limiting login"," — Correct for abuse prevention, but the business requirement was handling 100 legitimate concurrent logins during peak events. Rate limiting just moved the failure to the user.",[11,472,473,476],{},[21,474,475],{},"Bigger pods (2 CPU, 2Gi memory)"," — Improved throughput, raised cost, still shared the pool with the rest of the app. p95 dropped to ~2s but wasn't reliable under spikes.",[11,478,479,480,483],{},"We needed to ",[21,481,482],{},"stop running bcrypt inside the HTTP server process"," — not tune the same process harder.",[42,485,487],{"id":486},"the-fix-move-bcrypt-to-a-nestjs-native-script","The fix: move bcrypt to a NestJS native script",[11,489,490,491,494,495,498],{},"NestJS supports running code outside the HTTP server through a ",[21,492,493],{},"native script"," — a standalone entry point bootstrapped with ",[25,496,497],{},"NestFactory.createApplicationContext()",". No Express adapter, no port binding, no request middleware. Just your modules, DI, and the logic you need.",[11,500,501],{},"That turned out to be exactly what we needed.",[503,504,506],"h3",{"id":505},"before-bcrypt-inside-the-login-handler","Before: bcrypt inside the login handler",[11,508,509],{},"The default NestJS pattern puts everything in one process:",[267,511,515],{"className":512,"code":513,"language":514,"meta":272,"style":272},"language-typescript shiki shiki-themes github-dark","\u002F\u002F auth.service.ts — inside the HTTP app\nasync login(email: string, password: string) {\n  const user = await this.users.findByEmail(email)\n  const valid = await bcrypt.compare(password, user.passwordHash) \u002F\u002F blocks the thread pool\n  if (!valid) throw new UnauthorizedException()\n  return this.signToken(user)\n}\n","typescript",[25,516,517,523,534,561,585,612,628],{"__ignoreMap":272},[276,518,519],{"class":278,"line":279},[276,520,522],{"class":521},"sAwPA","\u002F\u002F auth.service.ts — inside the HTTP app\n",[276,524,525,528,531],{"class":278,"line":319},[276,526,527],{"class":282},"async ",[276,529,530],{"class":293},"login",[276,532,533],{"class":282},"(email: string, password: string) {\n",[276,535,536,539,543,546,549,552,555,558],{"class":278,"line":334},[276,537,538],{"class":285},"  const",[276,540,542],{"class":541},"sDLfK"," user",[276,544,545],{"class":285}," =",[276,547,548],{"class":285}," await",[276,550,551],{"class":541}," this",[276,553,554],{"class":282},".users.",[276,556,557],{"class":293},"findByEmail",[276,559,560],{"class":282},"(email)\n",[276,562,564,566,569,571,573,576,579,582],{"class":278,"line":563},4,[276,565,538],{"class":285},[276,567,568],{"class":541}," valid",[276,570,545],{"class":285},[276,572,548],{"class":285},[276,574,575],{"class":282}," bcrypt.",[276,577,578],{"class":293},"compare",[276,580,581],{"class":282},"(password, user.passwordHash) ",[276,583,584],{"class":521},"\u002F\u002F blocks the thread pool\n",[276,586,588,591,594,597,600,603,606,609],{"class":278,"line":587},5,[276,589,590],{"class":285},"  if",[276,592,593],{"class":282}," (",[276,595,596],{"class":285},"!",[276,598,599],{"class":282},"valid) ",[276,601,602],{"class":285},"throw",[276,604,605],{"class":285}," new",[276,607,608],{"class":293}," UnauthorizedException",[276,610,611],{"class":282},"()\n",[276,613,615,618,620,622,625],{"class":278,"line":614},6,[276,616,617],{"class":285},"  return",[276,619,551],{"class":541},[276,621,168],{"class":282},[276,623,624],{"class":293},"signToken",[276,626,627],{"class":282},"(user)\n",[276,629,631],{"class":278,"line":630},7,[276,632,633],{"class":282},"}\n",[11,635,636],{},"Under 100 concurrent logins, every request hit the same libuv thread pool. Health checks, cron jobs, and other endpoints shared that pool. Everything queued.",[503,638,640],{"id":639},"after-bcrypt-in-a-separate-nestjs-script","After: bcrypt in a separate NestJS script",[11,642,643],{},"We added a second entry point in the same project:",[267,645,650],{"className":646,"code":648,"language":649},[647],"language-text","src\u002F\n  main.ts              ← HTTP API (unchanged entry)\n  scripts\u002F\n    verify-password.ts ← native script (new entry)\n","text",[25,651,648],{"__ignoreMap":272},[11,653,654],{},"The native script bootstraps only what it needs:",[267,656,658],{"className":512,"code":657,"language":514,"meta":272,"style":272},"\u002F\u002F scripts\u002Fverify-password.ts\nasync function bootstrap() {\n  const app = await NestFactory.createApplicationContext(AuthScriptModule, {\n    logger: false,\n  })\n\n  const verifier = app.get(PasswordVerifierService)\n\n  \u002F\u002F Read { hash, candidate } from stdin, write result to stdout\n  const input = JSON.parse(await readStdin())\n  const valid = await verifier.compare(input.candidate, input.hash)\n  process.stdout.write(JSON.stringify({ valid }))\n\n  await app.close()\n}\n",[25,659,660,665,679,699,710,715,721,739,744,750,780,799,821,826,839],{"__ignoreMap":272},[276,661,662],{"class":278,"line":279},[276,663,664],{"class":521},"\u002F\u002F scripts\u002Fverify-password.ts\n",[276,666,667,670,673,676],{"class":278,"line":319},[276,668,669],{"class":285},"async",[276,671,672],{"class":285}," function",[276,674,675],{"class":293}," bootstrap",[276,677,678],{"class":282},"() {\n",[276,680,681,683,686,688,690,693,696],{"class":278,"line":334},[276,682,538],{"class":285},[276,684,685],{"class":541}," app",[276,687,545],{"class":285},[276,689,548],{"class":285},[276,691,692],{"class":282}," NestFactory.",[276,694,695],{"class":293},"createApplicationContext",[276,697,698],{"class":282},"(AuthScriptModule, {\n",[276,700,701,704,707],{"class":278,"line":563},[276,702,703],{"class":282},"    logger: ",[276,705,706],{"class":541},"false",[276,708,709],{"class":282},",\n",[276,711,712],{"class":278,"line":587},[276,713,714],{"class":282},"  })\n",[276,716,717],{"class":278,"line":614},[276,718,720],{"emptyLinePlaceholder":719},true,"\n",[276,722,723,725,728,730,733,736],{"class":278,"line":630},[276,724,538],{"class":285},[276,726,727],{"class":541}," verifier",[276,729,545],{"class":285},[276,731,732],{"class":282}," app.",[276,734,735],{"class":293},"get",[276,737,738],{"class":282},"(PasswordVerifierService)\n",[276,740,742],{"class":278,"line":741},8,[276,743,720],{"emptyLinePlaceholder":719},[276,745,747],{"class":278,"line":746},9,[276,748,749],{"class":521},"  \u002F\u002F Read { hash, candidate } from stdin, write result to stdout\n",[276,751,753,755,758,760,763,765,768,771,774,777],{"class":278,"line":752},10,[276,754,538],{"class":285},[276,756,757],{"class":541}," input",[276,759,545],{"class":285},[276,761,762],{"class":541}," JSON",[276,764,168],{"class":282},[276,766,767],{"class":293},"parse",[276,769,770],{"class":282},"(",[276,772,773],{"class":285},"await",[276,775,776],{"class":293}," readStdin",[276,778,779],{"class":282},"())\n",[276,781,783,785,787,789,791,794,796],{"class":278,"line":782},11,[276,784,538],{"class":285},[276,786,568],{"class":541},[276,788,545],{"class":285},[276,790,548],{"class":285},[276,792,793],{"class":282}," verifier.",[276,795,578],{"class":293},[276,797,798],{"class":282},"(input.candidate, input.hash)\n",[276,800,802,805,808,810,813,815,818],{"class":278,"line":801},12,[276,803,804],{"class":282},"  process.stdout.",[276,806,807],{"class":293},"write",[276,809,770],{"class":282},[276,811,812],{"class":541},"JSON",[276,814,168],{"class":282},[276,816,817],{"class":293},"stringify",[276,819,820],{"class":282},"({ valid }))\n",[276,822,824],{"class":278,"line":823},13,[276,825,720],{"emptyLinePlaceholder":719},[276,827,829,832,834,837],{"class":278,"line":828},14,[276,830,831],{"class":285},"  await",[276,833,732],{"class":282},[276,835,836],{"class":293},"close",[276,838,611],{"class":282},[276,840,842],{"class":278,"line":841},15,[276,843,633],{"class":282},[11,845,846,847,850],{},"Build it as a separate output in ",[25,848,849],{},"nest-cli.json",":",[267,852,856],{"className":853,"code":854,"language":855,"meta":272,"style":272},"language-json shiki shiki-themes github-dark","{\n  \"compilerOptions\": {\n    \"assets\": [],\n    \"plugins\": []\n  },\n  \"projects\": {\n    \"api\": { \"type\": \"application\", \"root\": \"src\", \"entryFile\": \"main\" },\n    \"verify-password\": {\n      \"type\": \"application\",\n      \"root\": \"src\",\n      \"entryFile\": \"scripts\u002Fverify-password\"\n    }\n  }\n}\n","json",[25,857,858,863,871,879,887,892,899,939,946,957,968,978,983,988],{"__ignoreMap":272},[276,859,860],{"class":278,"line":279},[276,861,862],{"class":282},"{\n",[276,864,865,868],{"class":278,"line":319},[276,866,867],{"class":541},"  \"compilerOptions\"",[276,869,870],{"class":282},": {\n",[276,872,873,876],{"class":278,"line":334},[276,874,875],{"class":541},"    \"assets\"",[276,877,878],{"class":282},": [],\n",[276,880,881,884],{"class":278,"line":563},[276,882,883],{"class":541},"    \"plugins\"",[276,885,886],{"class":282},": []\n",[276,888,889],{"class":278,"line":587},[276,890,891],{"class":282},"  },\n",[276,893,894,897],{"class":278,"line":614},[276,895,896],{"class":541},"  \"projects\"",[276,898,870],{"class":282},[276,900,901,904,907,910,912,915,918,921,923,926,928,931,933,936],{"class":278,"line":630},[276,902,903],{"class":541},"    \"api\"",[276,905,906],{"class":282},": { ",[276,908,909],{"class":541},"\"type\"",[276,911,328],{"class":282},[276,913,914],{"class":289},"\"application\"",[276,916,917],{"class":282},", ",[276,919,920],{"class":541},"\"root\"",[276,922,328],{"class":282},[276,924,925],{"class":289},"\"src\"",[276,927,917],{"class":282},[276,929,930],{"class":541},"\"entryFile\"",[276,932,328],{"class":282},[276,934,935],{"class":289},"\"main\"",[276,937,938],{"class":282}," },\n",[276,940,941,944],{"class":278,"line":741},[276,942,943],{"class":541},"    \"verify-password\"",[276,945,870],{"class":282},[276,947,948,951,953,955],{"class":278,"line":746},[276,949,950],{"class":541},"      \"type\"",[276,952,328],{"class":282},[276,954,914],{"class":289},[276,956,709],{"class":282},[276,958,959,962,964,966],{"class":278,"line":752},[276,960,961],{"class":541},"      \"root\"",[276,963,328],{"class":282},[276,965,925],{"class":289},[276,967,709],{"class":282},[276,969,970,973,975],{"class":278,"line":782},[276,971,972],{"class":541},"      \"entryFile\"",[276,974,328],{"class":282},[276,976,977],{"class":289},"\"scripts\u002Fverify-password\"\n",[276,979,980],{"class":278,"line":801},[276,981,982],{"class":282},"    }\n",[276,984,985],{"class":278,"line":823},[276,986,987],{"class":282},"  }\n",[276,989,990],{"class":278,"line":828},[276,991,633],{"class":282},[11,993,994,995,997],{},"The login handler no longer calls ",[25,996,27],{}," directly. It delegates to the script:",[267,999,1001],{"className":512,"code":1000,"language":514,"meta":272,"style":272},"\u002F\u002F auth.service.ts — HTTP app, no bcrypt import\nasync login(email: string, password: string) {\n  const user = await this.users.findByEmail(email)\n  const { valid } = await this.scriptRunner.run('verify-password', {\n    candidate: password,\n    hash: user.passwordHash,\n  })\n  if (!valid) throw new UnauthorizedException()\n  return this.signToken(user)\n}\n",[25,1002,1003,1008,1016,1034,1067,1072,1077,1081,1099,1111],{"__ignoreMap":272},[276,1004,1005],{"class":278,"line":279},[276,1006,1007],{"class":521},"\u002F\u002F auth.service.ts — HTTP app, no bcrypt import\n",[276,1009,1010,1012,1014],{"class":278,"line":319},[276,1011,527],{"class":282},[276,1013,530],{"class":293},[276,1015,533],{"class":282},[276,1017,1018,1020,1022,1024,1026,1028,1030,1032],{"class":278,"line":334},[276,1019,538],{"class":285},[276,1021,542],{"class":541},[276,1023,545],{"class":285},[276,1025,548],{"class":285},[276,1027,551],{"class":541},[276,1029,554],{"class":282},[276,1031,557],{"class":293},[276,1033,560],{"class":282},[276,1035,1036,1038,1041,1044,1047,1049,1051,1053,1056,1059,1061,1064],{"class":278,"line":563},[276,1037,538],{"class":285},[276,1039,1040],{"class":282}," { ",[276,1042,1043],{"class":541},"valid",[276,1045,1046],{"class":282}," } ",[276,1048,286],{"class":285},[276,1050,548],{"class":285},[276,1052,551],{"class":541},[276,1054,1055],{"class":282},".scriptRunner.",[276,1057,1058],{"class":293},"run",[276,1060,770],{"class":282},[276,1062,1063],{"class":289},"'verify-password'",[276,1065,1066],{"class":282},", {\n",[276,1068,1069],{"class":278,"line":587},[276,1070,1071],{"class":282},"    candidate: password,\n",[276,1073,1074],{"class":278,"line":614},[276,1075,1076],{"class":282},"    hash: user.passwordHash,\n",[276,1078,1079],{"class":278,"line":630},[276,1080,714],{"class":282},[276,1082,1083,1085,1087,1089,1091,1093,1095,1097],{"class":278,"line":741},[276,1084,590],{"class":285},[276,1086,593],{"class":282},[276,1088,596],{"class":285},[276,1090,599],{"class":282},[276,1092,602],{"class":285},[276,1094,605],{"class":285},[276,1096,608],{"class":293},[276,1098,611],{"class":282},[276,1100,1101,1103,1105,1107,1109],{"class":278,"line":746},[276,1102,617],{"class":285},[276,1104,551],{"class":541},[276,1106,168],{"class":282},[276,1108,624],{"class":293},[276,1110,627],{"class":282},[276,1112,1113],{"class":278,"line":752},[276,1114,633],{"class":282},[11,1116,1117,1120,1121,1124,1125,1128],{},[25,1118,1119],{},"ScriptRunner"," spawns the native script as a child process via ",[25,1122,1123],{},"child_process.spawn",". Each compare runs in its ",[21,1126,1127],{},"own Node.js process with its own thread pool"," — completely isolated from the HTTP server.",[503,1130,1132],{"id":1131},"why-this-reduces-the-bottleneck","Why this reduces the bottleneck",[11,1134,1135,1136,850],{},"The improvement isn't magic — it's ",[21,1137,1138],{},"process isolation",[203,1140,1141,1153],{},[206,1142,1143],{},[209,1144,1145,1147,1150],{},[212,1146],{},[212,1148,1149],{},"HTTP server (before)",[212,1151,1152],{},"Native script (after)",[219,1154,1155,1166,1177,1188],{},[209,1156,1157,1160,1163],{},[224,1158,1159],{},"Thread pool",[224,1161,1162],{},"Shared with all requests",[224,1164,1165],{},"Dedicated per compare",[209,1167,1168,1171,1174],{},[224,1169,1170],{},"CPU competition",[224,1172,1173],{},"bcrypt vs health checks vs cron",[224,1175,1176],{},"bcrypt only",[209,1178,1179,1182,1185],{},[224,1180,1181],{},"Under 100 VUs",[224,1183,1184],{},"100 compares queue on 4 threads",[224,1186,1187],{},"API spawns compares in parallel processes",[209,1189,1190,1193,1196],{},[224,1191,1192],{},"API event loop",[224,1194,1195],{},"Blocked waiting on pool",[224,1197,1198],{},"Free for I\u002FO",[11,1200,1201,1202,1205],{},"Each ",[25,1203,1204],{},"verify-password"," script process exits after one compare. The overhead of spawning a process (~10–20ms) is negligible compared to a 6-second queue wait.",[11,1207,1208,1209,1211],{},"On Kubernetes, the HTTP deployment stays lean. No need to oversized ",[25,1210,35],{}," on the API pod. The script processes inherit the pod's CPU limit but don't block each other inside a single event loop.",[503,1213,1215],{"id":1214},"deploying-on-kubernetes","Deploying on Kubernetes",[11,1217,1218],{},"Same Docker image, two commands:",[267,1220,1222],{"className":303,"code":1221,"language":305,"meta":272,"style":272},"# API deployment — handles HTTP only\ncontainers:\n  - name: api\n    image: my-app:latest\n    command: [\"node\", \"dist\u002Fmain.js\"]\n\n# No separate worker deployment needed.\n# Script runs on-demand via child_process inside the API pod.\n",[25,1223,1224,1229,1236,1247,1257,1276,1280,1285],{"__ignoreMap":272},[276,1225,1226],{"class":278,"line":279},[276,1227,1228],{"class":521},"# API deployment — handles HTTP only\n",[276,1230,1231,1234],{"class":278,"line":319},[276,1232,1233],{"class":312},"containers",[276,1235,316],{"class":282},[276,1237,1238,1240,1242,1244],{"class":278,"line":334},[276,1239,322],{"class":282},[276,1241,325],{"class":312},[276,1243,328],{"class":282},[276,1245,1246],{"class":289},"api\n",[276,1248,1249,1252,1254],{"class":278,"line":563},[276,1250,1251],{"class":312},"    image",[276,1253,328],{"class":282},[276,1255,1256],{"class":289},"my-app:latest\n",[276,1258,1259,1262,1265,1268,1270,1273],{"class":278,"line":587},[276,1260,1261],{"class":312},"    command",[276,1263,1264],{"class":282},": [",[276,1266,1267],{"class":289},"\"node\"",[276,1269,917],{"class":282},[276,1271,1272],{"class":289},"\"dist\u002Fmain.js\"",[276,1274,1275],{"class":282},"]\n",[276,1277,1278],{"class":278,"line":614},[276,1279,720],{"emptyLinePlaceholder":719},[276,1281,1282],{"class":278,"line":630},[276,1283,1284],{"class":521},"# No separate worker deployment needed.\n",[276,1286,1287],{"class":278,"line":741},[276,1288,1289],{"class":521},"# Script runs on-demand via child_process inside the API pod.\n",[11,1291,1292],{},"We also tested a variant where the script runs as a long-lived sidecar process (still a NestJS native script, just kept alive). Results were similar, but spawn-per-request was simpler to ship and easier to reason about under load.",[503,1294,1296],{"id":1295},"results-after-the-change","Results after the change",[11,1298,1299],{},"Same k6 script, same 100 VUs, same k8s cluster:",[203,1301,1302,1315],{},[206,1303,1304],{},[209,1305,1306,1309,1312],{},[212,1307,1308],{},"Metric",[212,1310,1311],{},"Before",[212,1313,1314],{},"After",[219,1316,1317,1328,1339,1350,1361],{},[209,1318,1319,1322,1325],{},[224,1320,1321],{},"p95 login latency",[224,1323,1324],{},"15,000ms+",[224,1326,1327],{},"380ms",[209,1329,1330,1333,1336],{},[224,1331,1332],{},"p99 login latency",[224,1334,1335],{},"30,000ms (timeout)",[224,1337,1338],{},"650ms",[209,1340,1341,1344,1347],{},[224,1342,1343],{},"Error rate",[224,1345,1346],{},"34%",[224,1348,1349],{},"0%",[209,1351,1352,1355,1358],{},[224,1353,1354],{},"bcrypt compare (wall clock)",[224,1356,1357],{},"up to 6,000ms",[224,1359,1360],{},"90–110ms",[209,1362,1363,1366,1369],{},[224,1364,1365],{},"API pod CPU during test",[224,1367,1368],{},"~95%",[224,1370,1371],{},"~30%",[11,1373,1374],{},"The API pod stopped fighting bcrypt for CPU. Compare time dropped because each verification got its own process instead of waiting in a shared queue.",[42,1376,1378],{"id":1377},"lessons-id-pass-on","Lessons I'd pass on",[11,1380,1381,1384,1385,1388],{},[21,1382,1383],{},"Load-test the auth path separately."," Login is not a CRUD endpoint. It has different CPU characteristics and failure modes. A load test that only hits ",[25,1386,1387],{},"GET \u002Fproducts"," will miss this entirely.",[11,1390,1391,1397],{},[21,1392,1393,1394,1396],{},"Don't fix a process isolation problem with ",[25,1395,35],{}," alone."," Raising the pool helps in dev, but on k8s with CPU limits you're still cramming all concurrent work into one Node.js process. A NestJS native script gives you isolation without a new microservice.",[11,1399,1400,1403],{},[21,1401,1402],{},"Kubernetes CPU limits multiply every Node.js concurrency footgun."," If your pod has 500m CPU, assume you can sustain far fewer parallel bcrypt operations than your thread pool size suggests.",[11,1405,1406,1409,1410,1413],{},[21,1407,1408],{},"Measure wall-clock time, not just handler time."," Our APM showed login handlers taking 6 seconds total and initially blamed the database. Only when we logged timestamps ",[127,1411,1412],{},"around"," the compare call did we see the queueing gap.",[11,1415,1416,1419,1420,1422],{},[21,1417,1418],{},"Use NestJS native scripts before reaching for a new service."," ",[25,1421,497],{}," is built for exactly this — reuse your modules and DI, run CPU-heavy work in a separate process, zero HTTP overhead. No Redis queue, no gRPC, no second deployment required.",[42,1424,1426],{"id":1425},"the-pain-point-in-one-sentence","The pain point in one sentence",[1428,1429,1430],"blockquote",{},[11,1431,1432],{},"Node.js login endpoints using bcrypt will silently queue under concurrent load — and Kubernetes CPU limits turn a thread pool tuning problem into a production outage.",[11,1434,1435],{},"If you're running NestJS on k8s and haven't load-tested login at realistic concurrency, do it this week. The failure mode is invisible until it isn't.",[42,1437,1439],{"id":1438},"related-reading","Related reading",[50,1441,1442,1450],{},[53,1443,1444,1449],{},[1445,1446,1448],"a",{"href":1447},"\u002Fblog\u002Fdocker-container-alerts-missing","Docker Containers Die Quietly — And Nobody Gets Paged"," — another production failure that only shows up under real load",[53,1451,1452,1456],{},[1445,1453,1455],{"href":1454},"\u002Fblog\u002Femail-deliverability-pain-points","Why Email Deliverability Is Still Broken in 2025"," — silent degradation patterns in production systems",[1458,1459,1460],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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 pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":272,"searchDepth":319,"depth":319,"links":1462},[1463,1464,1465,1466,1467,1468,1469,1476,1477,1478],{"id":44,"depth":319,"text":45},{"id":95,"depth":319,"text":96},{"id":171,"depth":319,"text":172},{"id":261,"depth":319,"text":262},{"id":390,"depth":319,"text":391},{"id":442,"depth":319,"text":443},{"id":486,"depth":319,"text":487,"children":1470},[1471,1472,1473,1474,1475],{"id":505,"depth":334,"text":506},{"id":639,"depth":334,"text":640},{"id":1131,"depth":334,"text":1132},{"id":1214,"depth":334,"text":1215},{"id":1295,"depth":334,"text":1296},{"id":1377,"depth":319,"text":1378},{"id":1425,"depth":319,"text":1426},{"id":1438,"depth":319,"text":1439},"2026-06-14","Login load tests at 100 VUs timed out on Kubernetes. A single bcrypt.compare took 6 seconds. UV_THREADPOOL_SIZE wasn't enough. We fixed it by moving bcrypt out of the HTTP server into a NestJS native script.",false,"md",null,[1485,35,1486,1487,1488,1489,1490,1491],"bcrypt bottleneck","NestJS performance","Kubernetes load test","login timeout","Node.js thread pool","password hashing","k6 load testing",{},"\u002Fblog\u002Fbcrypt-login-bottleneck-kubernetes",{"title":5,"description":1480},"blog\u002Fbcrypt-login-bottleneck-kubernetes",[1497,1498,1499,1500,1501],"nestjs","nodejs","kubernetes","performance","pain-point","gsQXKPLuX2PP-7lAe9VVgGxe6ICSv75DEg5xkpKXt90",1781441628655]