[{"data":1,"prerenderedAt":1524},["ShallowReactive",2],{"\u002Fblog\u002F2026\u002Fhow-i-built-a-mini-cache-engine":3},{"id":4,"title":5,"body":6,"date":1510,"description":1511,"draft":1512,"extension":1513,"meta":1514,"navigation":187,"path":1515,"seo":1516,"stem":1517,"summary":1518,"tags":1519,"__hash__":1523},"blog\u002Fblog\u002F2026\u002F02.how-i-built-a-mini-cache-engine.md","How I built a mini cache engine (Part 1)",{"type":7,"value":8,"toc":1498},"minimark",[9,13,16,21,24,29,32,35,38,41,44,48,51,65,69,72,82,85,88,344,348,351,358,361,516,519,593,603,608,611,968,979,983,986,993,1006,1011,1017,1087,1091,1104,1107,1249,1252,1258,1261,1478,1484,1488,1491,1494],[10,11,12],"p",{},"Hi there, this is part one of a two part series.",[10,14,15],{},"I want to give you a mini guide on how I built a mini cache engine\nlayer for a web application. This part covers content that is the same for\neveryone, and part two covers caching for logged in users.",[17,18,20],"h2",{"id":19},"what-is-the-cache-layer","What is the cache layer?",[10,22,23],{},"In the context of this blog, the cache layer refers to the layer\nthat sits between your frontend and a backend which caches responses\nto improve response times, and reduce the load on the backend service.",[25,26,28],"h3",{"id":27},"why-would-you-need-this","Why would you need this?",[10,30,31],{},"Imagine you have an application which displays content to users, like Stories,\nNews, Feeds, Blogs, etc., and it needs to be a fully rendered page so it can\nbe indexed by Google.",[10,33,34],{},"You might say, oh right, let's instead prebuild the pages so that they're\ncached (that works, yes) and static.",[10,36,37],{},"But when the content changes often, prebuilding can't keep up, so you'd server\nrender (SSR) the pages instead. And with SSR, every page visit renders on the\nserver, which means a call to the backend, which sometimes means a database\nread, even when nothing has changed since the last visit.",[10,39,40],{},"So anyone spamming a page is basically spamming your backend too (not fun).",[10,42,43],{},"That's where the cache layer comes in. Serve the same response again, and only\nbother the backend when you actually have to.",[17,45,47],{"id":46},"heres-what-i-did","Here's what I did",[10,49,50],{},"It didn't start as an engine initially. The first version was a catch-all\nroute that matched the request, built a cache key from it, and cached the\nresponse. No user caching, just content that looks the same for everyone.",[52,53,54],"blockquote",{},[10,55,56,57,64],{},"The code below uses ",[58,59,63],"a",{"href":60,"rel":61},"https:\u002F\u002Fgithub.com\u002Funjs\u002Fufo",[62],"nofollow","ufo"," for URL handling.",[25,66,68],{"id":67},"_1-figuring-out-what-to-cache","1. Figuring out what to cache",[10,70,71],{},"Not every backend call is worth caching, so for each one, I wondered...",[73,74,75,79],"ol",{},[76,77,78],"li",{},"How often is it read?",[76,80,81],{},"How long can it be old before anyone notices?",[10,83,84],{},"An about page can be a day old and nobody will complain, but the latest posts?\nNot more than a minute behind.",[10,86,87],{},"So instead of throwing a number at every call, I gave each kind of data a name,\nand kept all the cache times in one file.",[89,90,95],"pre",{"className":91,"code":92,"language":93,"meta":94,"style":94},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F utils\u002Fcache-times.ts\nconst minutesCache = (n: number) => n;\nconst hoursCache = (n: number) => n * 60;\n\nexport type CacheTypes = \"static\" | \"content\" | \"dynamic\";\n\nexport const cacheTimes: Record\u003CCacheTypes, number> = {\n  static: hoursCache(24),\n  content: hoursCache(1),\n  dynamic: minutesCache(1),\n};\n","ts","",[96,97,98,107,148,182,189,236,241,276,300,319,338],"code",{"__ignoreMap":94},[99,100,103],"span",{"class":101,"line":102},"line",1,[99,104,106],{"class":105},"sHwdD","\u002F\u002F utils\u002Fcache-times.ts\n",[99,108,110,114,118,122,125,129,132,136,139,142,145],{"class":101,"line":109},2,[99,111,113],{"class":112},"spNyl","const",[99,115,117],{"class":116},"sTEyZ"," minutesCache ",[99,119,121],{"class":120},"sMK4o","=",[99,123,124],{"class":120}," (",[99,126,128],{"class":127},"sHdIc","n",[99,130,131],{"class":120},":",[99,133,135],{"class":134},"sBMFI"," number",[99,137,138],{"class":120},")",[99,140,141],{"class":112}," =>",[99,143,144],{"class":116}," n",[99,146,147],{"class":120},";\n",[99,149,151,153,156,158,160,162,164,166,168,170,173,176,180],{"class":101,"line":150},3,[99,152,113],{"class":112},[99,154,155],{"class":116}," hoursCache ",[99,157,121],{"class":120},[99,159,124],{"class":120},[99,161,128],{"class":127},[99,163,131],{"class":120},[99,165,135],{"class":134},[99,167,138],{"class":120},[99,169,141],{"class":112},[99,171,172],{"class":116}," n ",[99,174,175],{"class":120},"*",[99,177,179],{"class":178},"sbssI"," 60",[99,181,147],{"class":120},[99,183,185],{"class":101,"line":184},4,[99,186,188],{"emptyLinePlaceholder":187},true,"\n",[99,190,192,196,199,202,205,208,212,215,218,220,223,225,227,229,232,234],{"class":101,"line":191},5,[99,193,195],{"class":194},"s7zQu","export",[99,197,198],{"class":112}," type",[99,200,201],{"class":134}," CacheTypes",[99,203,204],{"class":120}," =",[99,206,207],{"class":120}," \"",[99,209,211],{"class":210},"sfazB","static",[99,213,214],{"class":120},"\"",[99,216,217],{"class":120}," |",[99,219,207],{"class":120},[99,221,222],{"class":210},"content",[99,224,214],{"class":120},[99,226,217],{"class":120},[99,228,207],{"class":120},[99,230,231],{"class":210},"dynamic",[99,233,214],{"class":120},[99,235,147],{"class":120},[99,237,239],{"class":101,"line":238},6,[99,240,188],{"emptyLinePlaceholder":187},[99,242,244,246,249,252,254,257,260,263,266,268,271,273],{"class":101,"line":243},7,[99,245,195],{"class":194},[99,247,248],{"class":112}," const",[99,250,251],{"class":116}," cacheTimes",[99,253,131],{"class":120},[99,255,256],{"class":134}," Record",[99,258,259],{"class":120},"\u003C",[99,261,262],{"class":134},"CacheTypes",[99,264,265],{"class":120},",",[99,267,135],{"class":134},[99,269,270],{"class":120},">",[99,272,204],{"class":120},[99,274,275],{"class":120}," {\n",[99,277,279,283,285,289,292,295,297],{"class":101,"line":278},8,[99,280,282],{"class":281},"swJcz","  static",[99,284,131],{"class":120},[99,286,288],{"class":287},"s2Zo4"," hoursCache",[99,290,291],{"class":116},"(",[99,293,294],{"class":178},"24",[99,296,138],{"class":116},[99,298,299],{"class":120},",\n",[99,301,303,306,308,310,312,315,317],{"class":101,"line":302},9,[99,304,305],{"class":281},"  content",[99,307,131],{"class":120},[99,309,288],{"class":287},[99,311,291],{"class":116},[99,313,314],{"class":178},"1",[99,316,138],{"class":116},[99,318,299],{"class":120},[99,320,322,325,327,330,332,334,336],{"class":101,"line":321},10,[99,323,324],{"class":281},"  dynamic",[99,326,131],{"class":120},[99,328,329],{"class":287}," minutesCache",[99,331,291],{"class":116},[99,333,314],{"class":178},[99,335,138],{"class":116},[99,337,299],{"class":120},[99,339,341],{"class":101,"line":340},11,[99,342,343],{"class":120},"};\n",[25,345,347],{"id":346},"_2-how-to-now-cache-it","2. How to now cache it?",[10,349,350],{},"With the cache times sorted, the next question was where the caching actually\nhappens.",[10,352,353,354,357],{},"Instead of a cached route for every backend endpoint, there's one catch-all\nroute, ",[96,355,356],{},"\u002Fapi\u002Fcache\u002F...",", that every cached request goes through. The cache time\nrides along in the URL, so the route knows how long to keep each response.",[10,359,360],{},"On the client, a small helper builds that URL.",[89,362,364],{"className":91,"code":363,"language":93,"meta":94,"style":94},"\u002F\u002F utils\u002Fcached-get.ts\nexport type CacheConfig = { type: CacheTypes };\n\nexport function cachedGet\u003CT>(path: string, cacheConfig: CacheConfig) {\n  const minutes = cacheTimes[cacheConfig.type];\n  return $fetch\u003CT>(`\u002Fapi\u002Fcache\u002F${minutes}\u002F${path}`);\n}\n",[96,365,366,371,394,398,437,466,511],{"__ignoreMap":94},[99,367,368],{"class":101,"line":102},[99,369,370],{"class":105},"\u002F\u002F utils\u002Fcached-get.ts\n",[99,372,373,375,377,380,382,385,387,389,391],{"class":101,"line":109},[99,374,195],{"class":194},[99,376,198],{"class":112},[99,378,379],{"class":134}," CacheConfig",[99,381,204],{"class":120},[99,383,384],{"class":120}," {",[99,386,198],{"class":281},[99,388,131],{"class":120},[99,390,201],{"class":134},[99,392,393],{"class":120}," };\n",[99,395,396],{"class":101,"line":150},[99,397,188],{"emptyLinePlaceholder":187},[99,399,400,402,405,408,410,413,416,419,421,424,426,429,431,433,435],{"class":101,"line":184},[99,401,195],{"class":194},[99,403,404],{"class":112}," function",[99,406,407],{"class":287}," cachedGet",[99,409,259],{"class":120},[99,411,412],{"class":134},"T",[99,414,415],{"class":120},">(",[99,417,418],{"class":127},"path",[99,420,131],{"class":120},[99,422,423],{"class":134}," string",[99,425,265],{"class":120},[99,427,428],{"class":127}," cacheConfig",[99,430,131],{"class":120},[99,432,379],{"class":134},[99,434,138],{"class":120},[99,436,275],{"class":120},[99,438,439,442,445,447,449,452,455,458,461,464],{"class":101,"line":191},[99,440,441],{"class":112},"  const",[99,443,444],{"class":116}," minutes",[99,446,204],{"class":120},[99,448,251],{"class":116},[99,450,451],{"class":281},"[",[99,453,454],{"class":116},"cacheConfig",[99,456,457],{"class":120},".",[99,459,460],{"class":116},"type",[99,462,463],{"class":281},"]",[99,465,147],{"class":120},[99,467,468,471,474,476,478,480,482,485,488,491,494,497,500,502,504,507,509],{"class":101,"line":238},[99,469,470],{"class":194},"  return",[99,472,473],{"class":287}," $fetch",[99,475,259],{"class":120},[99,477,412],{"class":134},[99,479,270],{"class":120},[99,481,291],{"class":281},[99,483,484],{"class":120},"`",[99,486,487],{"class":210},"\u002Fapi\u002Fcache\u002F",[99,489,490],{"class":120},"${",[99,492,493],{"class":116},"minutes",[99,495,496],{"class":120},"}",[99,498,499],{"class":210},"\u002F",[99,501,490],{"class":120},[99,503,418],{"class":116},[99,505,506],{"class":120},"}`",[99,508,138],{"class":281},[99,510,147],{"class":120},[99,512,513],{"class":101,"line":243},[99,514,515],{"class":120},"}\n",[10,517,518],{},"Each call then declares its own cache config, right where the request is made.",[89,520,522],{"className":91,"code":521,"language":93,"meta":94,"style":94},"getPosts(page: number) {\n  const cacheConfig: CacheConfig = { type: \"content\" };\n  return cachedGet(`blog\u002Fposts?page=${page}`, cacheConfig);\n}\n",[96,523,524,535,561,589],{"__ignoreMap":94},[99,525,526,529,532],{"class":101,"line":102},[99,527,528],{"class":287},"getPosts",[99,530,531],{"class":116},"(page: number) ",[99,533,534],{"class":120},"{\n",[99,536,537,539,541,543,545,547,549,551,553,555,557,559],{"class":101,"line":109},[99,538,441],{"class":112},[99,540,428],{"class":116},[99,542,131],{"class":120},[99,544,379],{"class":134},[99,546,204],{"class":120},[99,548,384],{"class":120},[99,550,198],{"class":281},[99,552,131],{"class":120},[99,554,207],{"class":120},[99,556,222],{"class":210},[99,558,214],{"class":120},[99,560,393],{"class":120},[99,562,563,565,567,569,571,574,576,579,581,583,585,587],{"class":101,"line":150},[99,564,470],{"class":194},[99,566,407],{"class":287},[99,568,291],{"class":281},[99,570,484],{"class":120},[99,572,573],{"class":210},"blog\u002Fposts?page=",[99,575,490],{"class":120},[99,577,578],{"class":116},"page",[99,580,506],{"class":120},[99,582,265],{"class":120},[99,584,428],{"class":116},[99,586,138],{"class":281},[99,588,147],{"class":120},[99,590,591],{"class":101,"line":184},[99,592,515],{"class":120},[10,594,595,596,599,600,457],{},"So ",[96,597,598],{},"getPosts(2)"," goes to ",[96,601,602],{},"\u002Fapi\u002Fcache\u002F60\u002Fblog\u002Fposts?page=2",[52,604,605],{},[10,606,607],{},"Initially, during the first few times of using the cache engine,\nI had placed the duration at the end of the requests, and realized after\nlots of inconvenient network checks that browsers usually show just the\nlast part of the URL :(",[10,609,610],{},"And then, for the server, a catch-all route which will extract the duration,\nalong with all other properties of the request, so it can be forwarded",[89,612,614],{"className":91,"code":613,"language":93,"meta":94,"style":94},"\u002F\u002F server\u002Fapi\u002Fcache\u002F[...path].ts\nimport { withQuery } from \"ufo\";\n\nexport default defineEventHandler(async (event) => {\n  const [minutes, ...rest] = getRouterParam(event, \"path\")!.split(\"\u002F\");\n  const path = rest.join(\"\u002F\");\n  const query = getQuery(event);\n\n  const fetchFromBackend = defineCachedFunction(\n    () => $fetch(path, { baseURL: useRuntimeConfig().apiBase, query }),\n    {\n      name: \"api\",\n      maxAge: Number(minutes) * 60,\n      swr: false,\n      getKey: () => withQuery(path, query),\n    },\n  );\n\n  return fetchFromBackend();\n});\n",[96,615,616,621,645,649,675,731,760,780,784,799,842,847,864,888,902,929,935,943,948,959],{"__ignoreMap":94},[99,617,618],{"class":101,"line":102},[99,619,620],{"class":105},"\u002F\u002F server\u002Fapi\u002Fcache\u002F[...path].ts\n",[99,622,623,626,628,631,634,637,639,641,643],{"class":101,"line":109},[99,624,625],{"class":194},"import",[99,627,384],{"class":120},[99,629,630],{"class":116}," withQuery",[99,632,633],{"class":120}," }",[99,635,636],{"class":194}," from",[99,638,207],{"class":120},[99,640,63],{"class":210},[99,642,214],{"class":120},[99,644,147],{"class":120},[99,646,647],{"class":101,"line":150},[99,648,188],{"emptyLinePlaceholder":187},[99,650,651,653,656,659,661,664,666,669,671,673],{"class":101,"line":184},[99,652,195],{"class":194},[99,654,655],{"class":194}," default",[99,657,658],{"class":287}," defineEventHandler",[99,660,291],{"class":116},[99,662,663],{"class":112},"async",[99,665,124],{"class":120},[99,667,668],{"class":127},"event",[99,670,138],{"class":120},[99,672,141],{"class":112},[99,674,275],{"class":120},[99,676,677,679,682,684,686,689,692,694,696,699,701,703,705,707,709,711,713,716,719,721,723,725,727,729],{"class":101,"line":191},[99,678,441],{"class":112},[99,680,681],{"class":120}," [",[99,683,493],{"class":116},[99,685,265],{"class":120},[99,687,688],{"class":120}," ...",[99,690,691],{"class":116},"rest",[99,693,463],{"class":120},[99,695,204],{"class":120},[99,697,698],{"class":287}," getRouterParam",[99,700,291],{"class":281},[99,702,668],{"class":116},[99,704,265],{"class":120},[99,706,207],{"class":120},[99,708,418],{"class":210},[99,710,214],{"class":120},[99,712,138],{"class":281},[99,714,715],{"class":120},"!.",[99,717,718],{"class":287},"split",[99,720,291],{"class":281},[99,722,214],{"class":120},[99,724,499],{"class":210},[99,726,214],{"class":120},[99,728,138],{"class":281},[99,730,147],{"class":120},[99,732,733,735,738,740,743,745,748,750,752,754,756,758],{"class":101,"line":238},[99,734,441],{"class":112},[99,736,737],{"class":116}," path",[99,739,204],{"class":120},[99,741,742],{"class":116}," rest",[99,744,457],{"class":120},[99,746,747],{"class":287},"join",[99,749,291],{"class":281},[99,751,214],{"class":120},[99,753,499],{"class":210},[99,755,214],{"class":120},[99,757,138],{"class":281},[99,759,147],{"class":120},[99,761,762,764,767,769,772,774,776,778],{"class":101,"line":243},[99,763,441],{"class":112},[99,765,766],{"class":116}," query",[99,768,204],{"class":120},[99,770,771],{"class":287}," getQuery",[99,773,291],{"class":281},[99,775,668],{"class":116},[99,777,138],{"class":281},[99,779,147],{"class":120},[99,781,782],{"class":101,"line":278},[99,783,188],{"emptyLinePlaceholder":187},[99,785,786,788,791,793,796],{"class":101,"line":302},[99,787,441],{"class":112},[99,789,790],{"class":116}," fetchFromBackend",[99,792,204],{"class":120},[99,794,795],{"class":287}," defineCachedFunction",[99,797,798],{"class":281},"(\n",[99,800,801,804,806,808,810,812,814,816,819,821,824,827,829,832,834,836,838,840],{"class":101,"line":321},[99,802,803],{"class":120},"    ()",[99,805,141],{"class":112},[99,807,473],{"class":287},[99,809,291],{"class":281},[99,811,418],{"class":116},[99,813,265],{"class":120},[99,815,384],{"class":120},[99,817,818],{"class":281}," baseURL",[99,820,131],{"class":120},[99,822,823],{"class":287}," useRuntimeConfig",[99,825,826],{"class":281},"()",[99,828,457],{"class":120},[99,830,831],{"class":116},"apiBase",[99,833,265],{"class":120},[99,835,766],{"class":116},[99,837,633],{"class":120},[99,839,138],{"class":281},[99,841,299],{"class":120},[99,843,844],{"class":101,"line":340},[99,845,846],{"class":120},"    {\n",[99,848,850,853,855,857,860,862],{"class":101,"line":849},12,[99,851,852],{"class":281},"      name",[99,854,131],{"class":120},[99,856,207],{"class":120},[99,858,859],{"class":210},"api",[99,861,214],{"class":120},[99,863,299],{"class":120},[99,865,867,870,872,875,877,879,882,884,886],{"class":101,"line":866},13,[99,868,869],{"class":281},"      maxAge",[99,871,131],{"class":120},[99,873,874],{"class":287}," Number",[99,876,291],{"class":281},[99,878,493],{"class":116},[99,880,881],{"class":281},") ",[99,883,175],{"class":120},[99,885,179],{"class":178},[99,887,299],{"class":120},[99,889,891,894,896,900],{"class":101,"line":890},14,[99,892,893],{"class":281},"      swr",[99,895,131],{"class":120},[99,897,899],{"class":898},"sfNiH"," false",[99,901,299],{"class":120},[99,903,905,908,910,913,915,917,919,921,923,925,927],{"class":101,"line":904},15,[99,906,907],{"class":287},"      getKey",[99,909,131],{"class":120},[99,911,912],{"class":120}," ()",[99,914,141],{"class":112},[99,916,630],{"class":287},[99,918,291],{"class":281},[99,920,418],{"class":116},[99,922,265],{"class":120},[99,924,766],{"class":116},[99,926,138],{"class":281},[99,928,299],{"class":120},[99,930,932],{"class":101,"line":931},16,[99,933,934],{"class":120},"    },\n",[99,936,938,941],{"class":101,"line":937},17,[99,939,940],{"class":281},"  )",[99,942,147],{"class":120},[99,944,946],{"class":101,"line":945},18,[99,947,188],{"emptyLinePlaceholder":187},[99,949,951,953,955,957],{"class":101,"line":950},19,[99,952,470],{"class":194},[99,954,790],{"class":287},[99,956,826],{"class":281},[99,958,147],{"class":120},[99,960,962,964,966],{"class":101,"line":961},20,[99,963,496],{"class":120},[99,965,138],{"class":116},[99,967,147],{"class":120},[10,969,970,971,974,975,978],{},"The key is the path plus the query string, so ",[96,972,973],{},"blog\u002Fposts?page=1"," and ",[96,976,977],{},"blog\u002Fposts?page=2","\nare cached separately.",[25,980,982],{"id":981},"_3-how-do-i-now-invalidate-this-cache","3. How do I now invalidate this cache?",[10,984,985],{},"They say cache invalidation is one of the hardest things in programming, and yes,\nit absolutely is. However, I still went the easy and simple route (since I'm lazy, of course).",[10,987,988,992],{},[989,990,991],"strong",{},"Just wait for it to expire."," Every entry is saved with the cache time from its URL.\nOnce that time runs out, the next request fetches fresh data from the backend, and\nmost of the time that's all you will really need.",[10,994,995,998,999,1002,1003,457],{},[989,996,997],{},"Or create a way to bust it manually."," If you can't wait for it to expire, like right after\npublishing a post, a request can ask for a fresh copy by adding (in this case) ",[96,1000,1001],{},"reset"," to\nthe query, e.g. ",[96,1004,1005],{},"blog\u002Fposts?page=1&reset=1",[1007,1008,1010],"h4",{"id":1009},"for-the-implementation","For the implementation",[10,1012,1013,1014,1016],{},"Pull out the ",[96,1015,1001],{}," from the query (so it's not added to the cache key),",[89,1018,1020],{"className":91,"code":1019,"language":93,"meta":94,"style":94},"\u002F\u002F server\u002Fapi\u002Fcache\u002F[...path].ts\nconst { reset, ...query } = getQuery(event);\n\n\u002F\u002F ...then in the defineCachedFunction options\nshouldInvalidateCache: () => reset === \"1\",\n",[96,1021,1022,1026,1053,1057,1062],{"__ignoreMap":94},[99,1023,1024],{"class":101,"line":102},[99,1025,620],{"class":105},[99,1027,1028,1030,1032,1035,1037,1039,1042,1044,1046,1048,1051],{"class":101,"line":109},[99,1029,113],{"class":112},[99,1031,384],{"class":120},[99,1033,1034],{"class":116}," reset",[99,1036,265],{"class":120},[99,1038,688],{"class":120},[99,1040,1041],{"class":116},"query ",[99,1043,496],{"class":120},[99,1045,204],{"class":120},[99,1047,771],{"class":287},[99,1049,1050],{"class":116},"(event)",[99,1052,147],{"class":120},[99,1054,1055],{"class":101,"line":150},[99,1056,188],{"emptyLinePlaceholder":187},[99,1058,1059],{"class":101,"line":184},[99,1060,1061],{"class":105},"\u002F\u002F ...then in the defineCachedFunction options\n",[99,1063,1064,1067,1069,1071,1073,1076,1079,1081,1083,1085],{"class":101,"line":191},[99,1065,1066],{"class":134},"shouldInvalidateCache",[99,1068,131],{"class":120},[99,1070,912],{"class":120},[99,1072,141],{"class":112},[99,1074,1075],{"class":116}," reset ",[99,1077,1078],{"class":120},"===",[99,1080,207],{"class":120},[99,1082,314],{"class":210},[99,1084,214],{"class":120},[99,1086,299],{"class":120},[25,1088,1090],{"id":1089},"_4-what-could-go-wrong","4. What could go wrong?",[10,1092,1093,1096,1097,974,1100,1103],{},[989,1094,1095],{},"Param order."," ",[96,1098,1099],{},"blog\u002Fposts?page=1&limit=10",[96,1101,1102],{},"blog\u002Fposts?limit=10&page=1","\nask for the same thing, but they're different strings. Since the key is built\nfrom the query string, they end up as two separate cache entries, and two\nbackend requests.",[10,1105,1106],{},"So before building the key, the query gets its keys sorted.",[89,1108,1110],{"className":91,"code":1109,"language":93,"meta":94,"style":94},"const objectKeySorter = (obj: Record\u003Cstring, any>) =>\n  Object.fromEntries(\n    Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)),\n  );\n\n\u002F\u002F ...then in the defineCachedFunction options\ngetKey: () => withQuery(path, objectKeySorter(query)),\n",[96,1111,1112,1146,1158,1209,1215,1219,1223],{"__ignoreMap":94},[99,1113,1114,1116,1119,1121,1123,1126,1128,1130,1132,1135,1137,1140,1143],{"class":101,"line":102},[99,1115,113],{"class":112},[99,1117,1118],{"class":116}," objectKeySorter ",[99,1120,121],{"class":120},[99,1122,124],{"class":120},[99,1124,1125],{"class":127},"obj",[99,1127,131],{"class":120},[99,1129,256],{"class":134},[99,1131,259],{"class":120},[99,1133,1134],{"class":134},"string",[99,1136,265],{"class":120},[99,1138,1139],{"class":134}," any",[99,1141,1142],{"class":120},">)",[99,1144,1145],{"class":112}," =>\n",[99,1147,1148,1151,1153,1156],{"class":101,"line":109},[99,1149,1150],{"class":116},"  Object",[99,1152,457],{"class":120},[99,1154,1155],{"class":287},"fromEntries",[99,1157,798],{"class":116},[99,1159,1160,1163,1165,1168,1171,1173,1176,1178,1181,1183,1186,1188,1191,1194,1196,1199,1201,1204,1207],{"class":101,"line":150},[99,1161,1162],{"class":116},"    Object",[99,1164,457],{"class":120},[99,1166,1167],{"class":287},"entries",[99,1169,1170],{"class":116},"(obj)",[99,1172,457],{"class":120},[99,1174,1175],{"class":287},"sort",[99,1177,291],{"class":116},[99,1179,1180],{"class":120},"([",[99,1182,58],{"class":127},[99,1184,1185],{"class":120},"],",[99,1187,681],{"class":120},[99,1189,1190],{"class":127},"b",[99,1192,1193],{"class":120},"])",[99,1195,141],{"class":112},[99,1197,1198],{"class":116}," a",[99,1200,457],{"class":120},[99,1202,1203],{"class":287},"localeCompare",[99,1205,1206],{"class":116},"(b))",[99,1208,299],{"class":120},[99,1210,1211,1213],{"class":101,"line":184},[99,1212,940],{"class":116},[99,1214,147],{"class":120},[99,1216,1217],{"class":101,"line":191},[99,1218,188],{"emptyLinePlaceholder":187},[99,1220,1221],{"class":101,"line":238},[99,1222,1061],{"class":105},[99,1224,1225,1228,1230,1232,1234,1236,1239,1241,1244,1247],{"class":101,"line":243},[99,1226,1227],{"class":134},"getKey",[99,1229,131],{"class":120},[99,1231,912],{"class":120},[99,1233,141],{"class":112},[99,1235,630],{"class":287},[99,1237,1238],{"class":116},"(path",[99,1240,265],{"class":120},[99,1242,1243],{"class":287}," objectKeySorter",[99,1245,1246],{"class":116},"(query))",[99,1248,299],{"class":120},[10,1250,1251],{},"Now both URLs share one key, whichever order the params come in.",[10,1253,1254,1257],{},[989,1255,1256],{},"Everyone fetching at once."," When a popular entry expires, every request that\ncomes in before the fresh one is saved misses the cache and goes straight to\nthe backend, which is exactly what we were trying to avoid in the first place.",[10,1259,1260],{},"So, keep a map of requests still in progress, and let late arrivals wait for\nthe one already running,",[89,1262,1264],{"className":91,"code":1263,"language":93,"meta":94,"style":94},"const inFlight = new Map\u003Cstring, Promise\u003Cunknown>>();\n\nexport function once\u003CT>(key: string, fetcher: () => Promise\u003CT>) {\n  if (inFlight.has(key)) return inFlight.get(key) as Promise\u003CT>;\n\n  const promise = fetcher().finally(() => inFlight.delete(key));\n  inFlight.set(key, promise);\n  return promise;\n}\n",[96,1265,1266,1302,1306,1349,1400,1404,1444,1466,1474],{"__ignoreMap":94},[99,1267,1268,1270,1273,1275,1278,1281,1283,1285,1287,1290,1292,1295,1298,1300],{"class":101,"line":102},[99,1269,113],{"class":112},[99,1271,1272],{"class":116}," inFlight ",[99,1274,121],{"class":120},[99,1276,1277],{"class":120}," new",[99,1279,1280],{"class":287}," Map",[99,1282,259],{"class":120},[99,1284,1134],{"class":134},[99,1286,265],{"class":120},[99,1288,1289],{"class":134}," Promise",[99,1291,259],{"class":120},[99,1293,1294],{"class":134},"unknown",[99,1296,1297],{"class":120},">>",[99,1299,826],{"class":116},[99,1301,147],{"class":120},[99,1303,1304],{"class":101,"line":109},[99,1305,188],{"emptyLinePlaceholder":187},[99,1307,1308,1310,1312,1315,1317,1319,1321,1324,1326,1328,1330,1333,1335,1337,1339,1341,1343,1345,1347],{"class":101,"line":150},[99,1309,195],{"class":194},[99,1311,404],{"class":112},[99,1313,1314],{"class":287}," once",[99,1316,259],{"class":120},[99,1318,412],{"class":134},[99,1320,415],{"class":120},[99,1322,1323],{"class":127},"key",[99,1325,131],{"class":120},[99,1327,423],{"class":134},[99,1329,265],{"class":120},[99,1331,1332],{"class":287}," fetcher",[99,1334,131],{"class":120},[99,1336,912],{"class":120},[99,1338,141],{"class":112},[99,1340,1289],{"class":134},[99,1342,259],{"class":120},[99,1344,412],{"class":134},[99,1346,1142],{"class":120},[99,1348,275],{"class":120},[99,1350,1351,1354,1356,1359,1361,1364,1366,1368,1371,1374,1377,1379,1382,1384,1386,1388,1391,1393,1395,1397],{"class":101,"line":184},[99,1352,1353],{"class":194},"  if",[99,1355,124],{"class":281},[99,1357,1358],{"class":116},"inFlight",[99,1360,457],{"class":120},[99,1362,1363],{"class":287},"has",[99,1365,291],{"class":281},[99,1367,1323],{"class":116},[99,1369,1370],{"class":281},")) ",[99,1372,1373],{"class":194},"return",[99,1375,1376],{"class":116}," inFlight",[99,1378,457],{"class":120},[99,1380,1381],{"class":287},"get",[99,1383,291],{"class":281},[99,1385,1323],{"class":116},[99,1387,881],{"class":281},[99,1389,1390],{"class":194},"as",[99,1392,1289],{"class":134},[99,1394,259],{"class":120},[99,1396,412],{"class":134},[99,1398,1399],{"class":120},">;\n",[99,1401,1402],{"class":101,"line":191},[99,1403,188],{"emptyLinePlaceholder":187},[99,1405,1406,1408,1411,1413,1415,1417,1419,1422,1424,1426,1428,1430,1432,1435,1437,1439,1442],{"class":101,"line":238},[99,1407,441],{"class":112},[99,1409,1410],{"class":116}," promise",[99,1412,204],{"class":120},[99,1414,1332],{"class":287},[99,1416,826],{"class":281},[99,1418,457],{"class":120},[99,1420,1421],{"class":287},"finally",[99,1423,291],{"class":281},[99,1425,826],{"class":120},[99,1427,141],{"class":112},[99,1429,1376],{"class":116},[99,1431,457],{"class":120},[99,1433,1434],{"class":287},"delete",[99,1436,291],{"class":281},[99,1438,1323],{"class":116},[99,1440,1441],{"class":281},"))",[99,1443,147],{"class":120},[99,1445,1446,1449,1451,1454,1456,1458,1460,1462,1464],{"class":101,"line":243},[99,1447,1448],{"class":116},"  inFlight",[99,1450,457],{"class":120},[99,1452,1453],{"class":287},"set",[99,1455,291],{"class":281},[99,1457,1323],{"class":116},[99,1459,265],{"class":120},[99,1461,1410],{"class":116},[99,1463,138],{"class":281},[99,1465,147],{"class":120},[99,1467,1468,1470,1472],{"class":101,"line":278},[99,1469,470],{"class":194},[99,1471,1410],{"class":116},[99,1473,147],{"class":120},[99,1475,1476],{"class":101,"line":302},[99,1477,515],{"class":120},[10,1479,1480,1481,1483],{},"The ",[96,1482,1421],{}," is important here. It removes the entry once the request is done\n(whether it worked or failed), so the map never keeps growing.",[17,1485,1487],{"id":1486},"conclusion","Conclusion",[10,1489,1490],{},"That's the cache for content everyone sees the same way. In part 2, I'll go\nthrough caching for logged in users, which was the hardest part of the whole\nthing.",[10,1492,1493],{},"Thank you for taking the time to read! :)",[1495,1496,1497],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}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 .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}",{"title":94,"searchDepth":109,"depth":109,"links":1499},[1500,1503,1509],{"id":19,"depth":109,"text":20,"children":1501},[1502],{"id":27,"depth":150,"text":28},{"id":46,"depth":109,"text":47,"children":1504},[1505,1506,1507,1508],{"id":67,"depth":150,"text":68},{"id":346,"depth":150,"text":347},{"id":981,"depth":150,"text":982},{"id":1089,"depth":150,"text":1090},{"id":1486,"depth":109,"text":1487},"2026-09-18","Part 1 of how I built a cache layer for an SSR Nuxt app, covering content that is the same for every user.",false,"md",{},"\u002Fblog\u002F2026\u002Fhow-i-built-a-mini-cache-engine",{"title":5,"description":1511},"blog\u002F2026\u002F02.how-i-built-a-mini-cache-engine","Every visit to an SSR page can end up hitting your backend, even when nothing has changed (not fun). This is part one of how I built a small cache layer in front of it, starting with content that looks the same for everyone. Part two covers logged in users.",[1520,1521,1522],"nuxt","nitro","caching","GHwRSvfuhYRQbwPloFO3RalPq8nKDCs3Zt_H-pgMqvI",1789778980296]