[{"data":1,"prerenderedAt":708},["ShallowReactive",2],{"post-\u002Fblog\u002Fhttpclient-connection-lifetime-observed":3},{"id":4,"title":5,"body":6,"book":696,"date":697,"description":698,"extension":699,"meta":700,"navigation":701,"path":702,"seo":703,"stem":704,"tags":705,"__hash__":707},"blog\u002Fblog\u002Fhttpclient-connection-lifetime-observed.md","HttpClient connection lifetime, observed",{"type":7,"value":8,"toc":684},"minimark",[9,14,22,46,49,62,70,74,77,96,99,103,140,146,150,156,159,169,172,183,189,200,203,215,221,224,234,240,246,258,264,273,284,293,300,303,310,332,341,352,358,361,365,368,485,491,499,512,525,537,575,599,614,635,638,661,665,674,680],[10,11,13],"h2",{"id":12},"background","Background",[15,16,17,21],"p",{},[18,19,20],"code",{},"HttpClient"," in .NET has two configuration knobs around connection lifetime. They look similar on paper, but they're solving different problems and the difference shows up the moment you watch a real connection:",[23,24,25,32],"ul",{},[26,27,28,31],"li",{},[18,29,30],{},"SocketsHttpHandler.PooledConnectionLifetime",": how long an individual pooled connection stays alive before it gets closed and replaced.",[26,33,34,37,38,41,42,45],{},[18,35,36],{},"IHttpClientBuilder.SetHandlerLifetime",": how long the entire ",[18,39,40],{},"HttpMessageHandler"," stays alive before ",[18,43,44],{},"IHttpClientFactory"," rotates it.",[15,47,48],{},"Both have been part of my setup for years, often together, without me ever sitting down to watch what they do on the wire. The mental model came from blog posts and the Microsoft docs, but never the receipts.",[15,50,51,52,57,58,61],{},"This is a small follow-up to ",[53,54,56],"a",{"href":55},"\u002Fblog\u002Fload-balancing-long-lived-connections-in-kubernetes","Load Balancing Long Lived Connections in Kubernetes"," from 2024, where I leaned on ",[18,59,60],{},"PooledConnectionLifetime"," as one of the recommended fixes without ever showing what it actually does to a connection.",[15,63,64,65],{},"Repo with the code: ",[53,66,67],{"href":67,"rel":68},"https:\u002F\u002Fgithub.com\u002Fhaiilong\u002Fdotnet-http-client-connection-test",[69],"nofollow",[10,71,73],{"id":72},"how-i-set-it-up","How I set it up",[15,75,76],{},"Two projects in the solution:",[23,78,79,90],{},[26,80,81,85,86,89],{},[82,83,84],"strong",{},"TestServer",": a small ASP.NET Core API. On every incoming request, it grabs ",[18,87,88],{},"HttpContext.Connection.Id"," (Kestrel assigns a unique ID to each TCP connection it accepts) and echoes it back. Same TCP connection means same ID. New TCP connection means new ID.",[26,91,92,95],{},[82,93,94],{},"TestClient",": a console app that hits the server in five different ways and logs the connection IDs it sees come back.",[15,97,98],{},"So the test is just: does each client setup reuse one connection, or does it open new ones, and if it rotates, when?",[10,100,102],{"id":101},"the-five-setups","The five setups",[104,105,106,112,118,124,130],"ol",{},[26,107,108,109,111],{},"One ",[18,110,20],{}," instance, used for every request.",[26,113,114,115,117],{},"A brand new ",[18,116,20],{}," instance for each request (the famous anti-pattern).",[26,119,120,121,123],{},"A typed client registered with ",[18,122,44],{},".",[26,125,126,127,123],{},"A typed client with ",[18,128,129],{},"SocketsHttpHandler.PooledConnectionLifetime = 3s",[26,131,132,133,136,137,139],{},"A typed client with a short ",[18,134,135],{},"SetHandlerLifetime"," and a long ",[18,138,60],{},". This is the one I actually wanted to look at.",[15,141,142,143,145],{},"The first three are sanity checks against the mental model. Number four shows what ",[18,144,60],{}," does on its own. Number five is the question I had: if you set the pool lifetime to \"long\" but the handler lifetime to \"short\", which one wins?",[10,147,149],{"id":148},"what-actually-happens","What actually happens",[15,151,152,155],{},[82,153,154],{},"Scenarios 1 to 3"," lined up with what I expected.",[15,157,158],{},"One client reused for every request: same connection ID, every time. The TCP connection is held open by HTTP keep-alive and reused for the life of the process.",[160,161,166],"pre",{"className":162,"code":164,"language":165},[163],"language-text","Request 1:  Connection OJN, 1st Request (took 3183ms)\nRequest 2:  Connection OJN, 2nd Request (took 5ms)\nRequest 3:  Connection OJN, 3rd Request (took 1ms)\nRequest 4:  Connection OJN, 4th Request (took 1ms)\nRequest 5:  Connection OJN, 5th Request (took 0ms)\n","text",[18,167,164],{"__ignoreMap":168},"",[15,170,171],{},"The first request paid the cold-start cost (TLS handshake, JIT warmup, the usual first-time overhead). Every subsequent request hit the warm connection and finished in single-digit milliseconds.",[15,173,174,175,178,179,182],{},"A new client per request: fresh connection ID every time, and (if you watch ",[18,176,177],{},"netstat",") a slow accumulation of sockets stuck in ",[18,180,181],{},"TIME_WAIT"," for the OS-default duration before the kernel cleans them up. The docs have called this out for years, but watching the count tick up locally is more concrete than reading about it.",[160,184,187],{"className":185,"code":186,"language":165},[163],"Request 1:  Connection OJO, 1st Request (took 2027ms)\nRequest 2:  Connection OJP, 1st Request (took 2073ms)\nRequest 3:  Connection OJQ, 1st Request (took 2023ms)\nRequest 4:  Connection OJR, 1st Request (took 2031ms)\nRequest 5:  Connection OJS, 1st Request (took 2026ms)\n",[18,188,186],{"__ignoreMap":168},[15,190,191,192,195,196,199],{},"Five different connection IDs (OJO through OJS) for five requests. There's no ",[18,193,194],{},"Task.Delay"," in this scenario; the ~2s per request is the actual cost of opening each new connection on this Windows setup, almost certainly dominated by the TLS handshake against the localhost dev cert (Windows does certificate chain validation, including possible revocation checks, surprisingly slowly). The contrast with scenario 1 is the lesson: a warm reused connection completed in under 5ms, while every cold connection here costs two full seconds. The \"don't ",[18,197,198],{},"new"," HttpClient per request\" rule isn't theoretical.",[15,201,202],{},"For context: in a Linux production setup talking to a known host in the same cloud region, a new TLS connection is usually well under 50ms, often less with TLS 1.3 and a warm DNS cache. The 2 second figure here is a Windows-localhost-dev-cert artifact. But cold-versus-warm is always going to be meaningfully slower in any environment; the magnitude just shifts with where you're running.",[15,204,205,206,208,209,211,212,214],{},"Typed client via ",[18,207,44],{},": same connection ID across requests. The factory keeps one ",[18,210,40],{}," alive in its internal cache and hands out lightweight ",[18,213,20],{}," wrappers around it. From the connection's point of view, every request through this client looks the same.",[160,216,219],{"className":217,"code":218,"language":165},[163],"Request 1:  Connection OJT, 1st Request (took 2051ms)\nRequest 2:  Connection OJT, 2nd Request (took 1ms)\nRequest 3:  Connection OJT, 3rd Request (took 1ms)\nRequest 4:  Connection OJT, 4th Request (took 1ms)\nRequest 5:  Connection OJT, 5th Request (took 1ms)\n",[18,220,218],{"__ignoreMap":168},[15,222,223],{},"Same shape as scenario 1: one connection (OJT), warm-up on the first hit, sub-millisecond for the rest.",[15,225,226,229,230,233],{},[82,227,228],{},"Scenario 4"," is the interesting one. With ",[18,231,232],{},"PooledConnectionLifetime = 3s",", the connection ID stayed the same for around three seconds, then flipped. Then stayed the same for another three seconds, then flipped again. One connection rotated at a time, the handler itself stayed put, no hiccup in the request stream.",[160,235,238],{"className":236,"code":237,"language":165},[163],"Request 1:  Connection OJU, 1st Request at 12:26:01 (took 2016ms)\nRequest 2:  Connection OJU, 2nd Request at 12:26:03 (took 3ms)\nRequest 3:  Connection OJV, 1st Request at 12:26:07 (took 2030ms)\nRequest 4:  Connection OJV, 2nd Request at 12:26:09 (took 3ms)\nRequest 5:  Connection OK0, 1st Request at 12:26:13 (took 2040ms)\n",[18,239,237],{"__ignoreMap":168},[15,241,242,243,245],{},"The pattern is exactly what ",[18,244,60],{}," is supposed to produce. Requests 1 and 2 share OJU because they happen within the 3 second window. Request 3, four seconds after request 2, arrives after OJU's pooled lifetime has expired, so the pool rotates and produces OJV. Requests 3 and 4 share OJV because they too land within a 3 second window. Request 5 hits after OJV has expired and OK0 takes over. Throughout all five requests the handler itself is the same; only the connections inside its pool cycle.",[15,247,248,251,252,254,255,257],{},[82,249,250],{},"Scenario 5"," is where I learned something. Before running it, I had quietly assumed that a long ",[18,253,60],{}," would protect existing connections even when the handler rotated underneath. It does not. As soon as ",[18,256,135],{}," expired and the factory swapped in a fresh handler, every subsequent request landed on a brand new connection ID, regardless of how much time those pooled connections had left on the clock.",[160,259,262],{"className":260,"code":261,"language":165},[163],"Request 1:  Connection OK1, 1st Request at 12:26:15 (took 2050ms)\nRequest 2:  Connection OK2, 1st Request at 12:26:19 (took 2043ms)\nRequest 3:  Connection OK3, 1st Request at 12:26:23 (took 2050ms)\nRequest 4:  Connection OK4, 1st Request at 12:26:27 (took 2042ms)\nRequest 5:  Connection OK5, 1st Request at 12:26:31 (took 2036ms)\n",[18,263,261],{"__ignoreMap":168},[15,265,266,267,269,270,272],{},"Five requests, five different connections (OK1, OK2, OK3, OK4, OK5). Requests are four seconds apart, ",[18,268,135],{}," is one second, so by the time each request lands, the previous handler has already aged out of the factory's active slot. A fresh handler means a fresh pool means a fresh connection. The long ",[18,271,60],{}," setting doesn't get a chance to matter because the pool it lives in has already been discarded.",[15,274,275,276,278,279,283],{},"Which makes sense once you think about it. ",[18,277,60],{}," is a property ",[280,281,282],"em",{},"on the handler's connection pool",". The pool lives inside the handler. Once the handler is disposed, the pool goes with it, and so do the connections. There is no shared connection state across handlers in the factory's cache.",[15,285,286,287,289,290,292],{},"So the two settings really are not interchangeable. ",[18,288,60],{}," rotates connections gracefully under a stable handler. ",[18,291,135],{}," resets the whole pool. If both fire, the handler one wins, because the pool only exists inside the handler.",[10,294,296,297,299],{"id":295},"a-short-detour-into-how-ihttpclientfactory-actually-works","A short detour into how ",[18,298,44],{}," actually works",[15,301,302],{},"Mapping this out is what made scenario 5 stop feeling surprising, so it's worth a paragraph.",[15,304,305,306,309],{},"When you call ",[18,307,308],{},"AddHttpClient(...)",", the factory keeps an internal cache mapping the client name to an \"active handler entry\". Each entry holds:",[23,311,312,322,325],{},[26,313,314,315,317,318,321],{},"The actual ",[18,316,40],{}," (which has its own connection pool, in the case of ",[18,319,320],{},"SocketsHttpHandler",").",[26,323,324],{},"A timestamp for when it was created.",[26,326,327,328,331],{},"The configured ",[18,329,330],{},"HandlerLifetime"," (default 2 minutes).",[15,333,334,335,337,338,340],{},"When you ask for an ",[18,336,20],{},", the factory checks the cache. If the active entry is still within its lifetime, you get a fresh ",[18,339,20],{}," wrapping the same handler, and therefore the same pool. If it's expired, the factory:",[104,342,343,346,349],{},[26,344,345],{},"Moves the expired entry to an \"expired handlers\" list.",[26,347,348],{},"Creates a new active entry with a fresh handler.",[26,350,351],{},"Starts a cleanup timer.",[15,353,354,355,357],{},"The expired handler is not disposed right away. The factory holds onto it for a grace period (4 minutes, hardcoded last I checked) so any in-flight ",[18,356,20],{}," instances that already hold a reference can finish their requests. Once that grace period passes and no references remain, the expired handler is disposed, which closes every connection in its pool.",[15,359,360],{},"That grace period is why in scenario 5 you don't see existing requests get interrupted. New requests start landing on a new handler's pool, which means a new connection. The old handler is still alive in the factory's expired list, waiting to be cleaned up.",[10,362,364],{"id":363},"what-i-actually-want-in-production","What I actually want in production",[15,366,367],{},"For long-running .NET services that talk to other services over HTTP (which, in a Kubernetes world, is most of them), the configuration I keep coming back to is:",[160,369,373],{"className":370,"code":371,"language":372,"meta":168,"style":168},"language-csharp shiki shiki-themes one-light one-dark-pro","services\n    .AddHttpClient\u003CMyClient>()\n    .ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler\n    {\n        PooledConnectionLifetime = TimeSpan.FromMinutes(2),\n    })\n    .SetHandlerLifetime(Timeout.InfiniteTimeSpan);\n","csharp",[18,374,375,384,405,426,432,459,465],{"__ignoreMap":168},[376,377,380],"span",{"class":378,"line":379},"line",1,[376,381,383],{"class":382},"sz0mV","services\n",[376,385,387,391,395,398,402],{"class":378,"line":386},2,[376,388,390],{"class":389},"s5ixo","    .",[376,392,394],{"class":393},"sAdtL","AddHttpClient",[376,396,397],{"class":389},"\u003C",[376,399,401],{"class":400},"sC09Y","MyClient",[376,403,404],{"class":389},">()\n",[376,406,408,410,413,416,420,423],{"class":378,"line":407},3,[376,409,390],{"class":389},[376,411,412],{"class":393},"ConfigurePrimaryHttpMessageHandler",[376,414,415],{"class":389},"(",[376,417,419],{"class":418},"s7GmK","_",[376,421,422],{"class":389}," => new ",[376,424,425],{"class":400},"SocketsHttpHandler\n",[376,427,429],{"class":378,"line":428},4,[376,430,431],{"class":389},"    {\n",[376,433,435,438,442,445,447,450,452,456],{"class":378,"line":434},5,[376,436,437],{"class":382},"        PooledConnectionLifetime",[376,439,441],{"class":440},"sknuh"," =",[376,443,444],{"class":418}," TimeSpan",[376,446,123],{"class":389},[376,448,449],{"class":393},"FromMinutes",[376,451,415],{"class":389},[376,453,455],{"class":454},"sAGMh","2",[376,457,458],{"class":389},"),\n",[376,460,462],{"class":378,"line":461},6,[376,463,464],{"class":389},"    })\n",[376,466,468,470,472,474,477,479,482],{"class":378,"line":467},7,[376,469,390],{"class":389},[376,471,135],{"class":393},[376,473,415],{"class":389},[376,475,476],{"class":418},"Timeout",[376,478,123],{"class":389},[376,480,481],{"class":418},"InfiniteTimeSpan",[376,483,484],{"class":389},");\n",[15,486,487,488,490],{},"One handler that stays alive for the whole app, with ",[18,489,60],{}," quietly rotating connections underneath it. If you set both to short values, you stack the handler-disposal pain on top of pool rotation, paying twice for what one of them already does.",[10,492,494,495,498],{"id":493},"why-timeoutinfinitetimespan-and-what-the-default-is","Why ",[18,496,497],{},"Timeout.InfiniteTimeSpan",", and what the default is",[15,500,501,502,504,505,508,509,511],{},"The default for ",[18,503,135],{}," is ",[82,506,507],{},"2 minutes",". If you call ",[18,510,308],{}," and never touch the lifetime, the factory will rotate the handler every two minutes for the life of your app.",[15,513,514,515,517,518,520,521,524],{},"That default exists for historical reasons. ",[18,516,44],{}," shipped in .NET Core 2.1 (2018) to solve two ",[18,519,20],{}," problems people kept hitting in production: socket exhaustion from ",[18,522,523],{},"new HttpClient()"," per request, and stale DNS on long-lived clients that never re-resolve. Its answer to the DNS problem was the heavy hammer: rotate the entire handler on a fixed schedule, dispose the old one after a grace period, force a fresh DNS lookup on the next request. Two minutes was a reasonable balance between DNS freshness and the cost of throwing the pool away.",[15,526,527,528,530,531,533,534,536],{},"The same release also introduced ",[18,529,320],{},", the fully managed HTTP handler that's been the underlying implementation under ",[18,532,20],{}," ever since. It exposes ",[18,535,60],{},", which does the DNS-refresh job at a finer grain than handler rotation: each pooled connection has its own age, and they cycle individually without disposing the handler or losing its TLS session tickets. For DNS refresh specifically, that's better in basically every measurable way than rotating the whole handler.",[15,538,539,540,557,558,561,562,564,565,567,568,570,571,574],{},"Worth being precise about which release did what, because it took me a while to untangle: ",[82,541,542,544,545,548,549,551,552],{},[18,543,320],{}," only became the default ",[280,546,547],{},"primary"," handler for ",[18,550,44],{}," in ",[53,553,556],{"href":554,"rel":555},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fdotnet\u002Fcore\u002Fcompatibility\u002Fnetworking\u002F9.0\u002Fdefault-handler",[69],".NET 9 Preview 6",". Before that, the factory's default primary handler was ",[18,559,560],{},"HttpClientHandler",", which is a thin wrapper around ",[18,563,320],{}," that does not expose ",[18,566,60],{},". So on .NET 8 and earlier, the only way to get ",[18,569,60],{}," in your factory setup was to explicitly opt in with ",[18,572,573],{},"ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler { ... })",", which is exactly what the config block above does.",[15,576,577,578,580,581,583,584,586,587,592,593,595,596,598],{},".NET 9 also added a nice touch: when the default primary handler is ",[18,579,320],{},", the factory now auto-sets ",[18,582,60],{}," to match ",[18,585,330],{}," if you don't configure either. The motivation, ",[53,588,591],{"href":589,"rel":590},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fdotnet\u002Fcore\u002Fcompatibility\u002Fnetworking\u002F9.0\u002Fdefault-handler#reason-for-change",[69],"per the docs",", is the singleton-capture footgun: if someone injects a typed client into a singleton service, the factory can no longer rotate that handler, and pre-.NET 9 the connections inside it would keep their stale DNS forever. With ",[18,594,60],{}," linked to ",[18,597,330],{}," by default, the underlying connections still rotate even when the handler doesn't.",[15,600,601,602,604,605,607,608,610,611,613],{},"The 2 minute default for ",[18,603,135],{}," itself never went away. Partly back-compat, partly because not every primary handler is ",[18,606,320],{},". People still pick ",[18,609,560],{}," explicitly for cookie or proxy property access, or run on .NET Framework where ",[18,612,320],{}," isn't supported at all. The factory can't assume the modern primitive is available.",[15,615,616,617,619,620,622,623,626,627,629,630,123],{},"If you're on a recent .NET and using ",[18,618,320],{}," (default since .NET 9, opt-in via ",[18,621,412],{}," before that), the recommendation is still to set ",[18,624,625],{},"SetHandlerLifetime(Timeout.InfiniteTimeSpan)"," and let ",[18,628,60],{}," do the rotation. Microsoft says as much in ",[53,631,634],{"href":632,"rel":633},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fdotnet\u002Ffundamentals\u002Fnetworking\u002Fhttp\u002Fhttpclient-guidelines",[69],"the current HttpClient guidelines",[15,636,637],{},"Practical rules of thumb:",[23,639,640,649,652],{},[26,641,642,643,645,646,648],{},"If you control your handler and you're on a recent .NET, set ",[18,644,625],{}," and configure ",[18,647,60],{}," to something sensible like 1 to 5 minutes.",[26,650,651],{},"If you don't configure anything, the 2 minute default still gives you DNS refresh, just less efficiently. It's not broken, it's doing things the old way.",[26,653,654,655,657,658,660],{},"If you're stuck with ",[18,656,560],{}," (legacy bind, custom handler chain), keep the default ",[18,659,135],{},". It's the only mechanism you have for DNS refresh.",[10,662,664],{"id":663},"closing","Closing",[15,666,667,668,670,671,673],{},"The thing I keep noticing when I write these small experiments up is how much more sticks after watching the thing run. I'd read about ",[18,669,60],{}," versus ",[18,672,135],{}," more times than I can count, but the difference only really clicked once the connection IDs started flipping on the screen.",[15,675,676,677],{},"Repo, again, if you want to clone and poke at it yourself: ",[53,678,67],{"href":67,"rel":679},[69],[681,682,683],"style",{},"html pre.shiki code .sz0mV, html code.shiki .sz0mV{--shiki-default:#383A42;--shiki-dark:#E06C75}html pre.shiki code .s5ixo, html code.shiki .s5ixo{--shiki-default:#383A42;--shiki-dark:#ABB2BF}html pre.shiki code .sAdtL, html code.shiki .sAdtL{--shiki-default:#4078F2;--shiki-dark:#61AFEF}html pre.shiki code .sC09Y, html code.shiki .sC09Y{--shiki-default:#C18401;--shiki-dark:#E5C07B}html pre.shiki code .s7GmK, html code.shiki .s7GmK{--shiki-default:#383A42;--shiki-dark:#E5C07B}html pre.shiki code .sknuh, html code.shiki .sknuh{--shiki-default:#383A42;--shiki-dark:#56B6C2}html pre.shiki code .sAGMh, html code.shiki .sAGMh{--shiki-default:#986801;--shiki-dark:#D19A66}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":168,"searchDepth":386,"depth":386,"links":685},[686,687,688,689,690,692,693,695],{"id":12,"depth":386,"text":13},{"id":72,"depth":386,"text":73},{"id":101,"depth":386,"text":102},{"id":148,"depth":386,"text":149},{"id":295,"depth":386,"text":691},"A short detour into how IHttpClientFactory actually works",{"id":363,"depth":386,"text":364},{"id":493,"depth":386,"text":694},"Why Timeout.InfiniteTimeSpan, and what the default is",{"id":663,"depth":386,"text":664},null,"2025-07-02","A small experiment to see what SetHandlerLifetime and PooledConnectionLifetime actually do to connection reuse.","md",{},true,"\u002Fblog\u002Fhttpclient-connection-lifetime-observed",{"title":5,"description":698},"blog\u002Fhttpclient-connection-lifetime-observed",[706],"tech","yuRscd9elsy9xy5u2VwI3JCcBUQJKcggU35sEvNEkDI",1778998257280]