[{"data":1,"prerenderedAt":2347},["ShallowReactive",2],{"blog-en-multi-channel-notification-engine":3,"blog-related-en-multi-channel-notification-engine":891},{"id":4,"title":5,"author":6,"body":7,"date":874,"description":875,"extension":876,"featured":877,"image":878,"meta":879,"navigation":288,"path":880,"readingTime":321,"seo":881,"slug":882,"stem":883,"tags":884,"__hash__":890},"blog\u002Fblog\u002Fen\u002Fmulti-channel-notification-engine.md","Building a Resilient Multi-Channel Notification Engine (Email, SMS, Web Push)","Adam Abdel-Djamal",{"type":8,"value":9,"toc":866},"minimark",[10,19,22,30,33,38,41,51,53,57,60,82,87,208,223,225,229,827,829,833,862],[11,12,13,14,18],"p",{},"Sending an email or SMS directly inside an HTTP request handler (",[15,16,17],"code",{},"await sendEmail(...)",") is an accident waiting to happen. If the third-party email API hangs for 10 seconds, your user's checkout button hangs for 10 seconds.",[11,20,21],{},"Worse, users have different communication preferences: some want order updates via WhatsApp or SMS, while others prefer email. If an SMS fails to deliver due to carrier issues, how does your system automatically fall back to email?",[11,23,24,25,29],{},"In this article, we'll architect a ",[26,27,28],"strong",{},"resilient, decoupled multi-channel notification engine"," powered by Redis job queues and prioritized channel dispatchers.",[31,32],"hr",{},[34,35,37],"h2",{"id":36},"_1-high-level-event-driven-architecture","1. High-Level Event-Driven Architecture",[11,39,40],{},"Instead of coupling application features to specific notification providers, the application emits an abstract domain event:",[42,43,49],"pre",{"className":44,"code":46,"language":47,"meta":48},[45],"language-text","[Order Service] ──► Emits Event: \"order.placed\"\n                          │\n                          ▼\n             [Notification Queue (Redis \u002F BullMQ)]\n                          │\n                          ▼\n            [Notification Worker Processor]\n             1. Load User Channel Preferences (Email? SMS? Push?)\n             2. Check Rate Limits & Do-Not-Disturb Quiet Hours\n             3. Render Channel-Specific Templates\n             4. Dispatch with Fallback Escalation\n","text","",[15,50,46],{"__ignoreMap":48},[31,52],{},[34,54,56],{"id":55},"_2-defining-channel-priority-and-fallback-logic","2. Defining Channel Priority and Fallback Logic",[11,58,59],{},"Not all channels carry the same urgency or cost:",[61,62,63,70,76],"ul",{},[64,65,66,69],"li",{},[26,67,68],{},"Web Push \u002F In-App Notification",": Free, instantaneous, but only works if the user is online.",[64,71,72,75],{},[26,73,74],{},"Email",": Very low cost ($0.0001), high reliability, ideal for receipts and long-form details.",[64,77,78,81],{},[26,79,80],{},"SMS \u002F WhatsApp",": High cost ($0.03 - $0.08), high open rate, reserved for critical alerts (2FA codes, payment failures).",[83,84,86],"h3",{"id":85},"fallback-matrix-example","Fallback Matrix Example:",[42,88,92],{"className":89,"code":90,"language":91,"meta":48,"style":48},"language-typescript shiki shiki-themes github-light github-dark","interface NotificationPayload {\n  userId: string\n  event: 'payment.failed' | 'order.delivered' | 'security.login_alert'\n  channels: Array\u003C'push' | 'sms' | 'email'>\n  data: Record\u003Cstring, any>\n}\n","typescript",[15,93,94,111,125,148,178,202],{"__ignoreMap":48},[95,96,99,103,107],"span",{"class":97,"line":98},"line",1,[95,100,102],{"class":101},"szBVR","interface",[95,104,106],{"class":105},"sScJk"," NotificationPayload",[95,108,110],{"class":109},"sVt8B"," {\n",[95,112,114,118,121],{"class":97,"line":113},2,[95,115,117],{"class":116},"s4XuR","  userId",[95,119,120],{"class":101},":",[95,122,124],{"class":123},"sj4cs"," string\n",[95,126,128,131,133,137,140,143,145],{"class":97,"line":127},3,[95,129,130],{"class":116},"  event",[95,132,120],{"class":101},[95,134,136],{"class":135},"sZZnC"," 'payment.failed'",[95,138,139],{"class":101}," |",[95,141,142],{"class":135}," 'order.delivered'",[95,144,139],{"class":101},[95,146,147],{"class":135}," 'security.login_alert'\n",[95,149,151,154,156,159,162,165,167,170,172,175],{"class":97,"line":150},4,[95,152,153],{"class":116},"  channels",[95,155,120],{"class":101},[95,157,158],{"class":105}," Array",[95,160,161],{"class":109},"\u003C",[95,163,164],{"class":135},"'push'",[95,166,139],{"class":101},[95,168,169],{"class":135}," 'sms'",[95,171,139],{"class":101},[95,173,174],{"class":135}," 'email'",[95,176,177],{"class":109},">\n",[95,179,181,184,186,189,191,194,197,200],{"class":97,"line":180},5,[95,182,183],{"class":116},"  data",[95,185,120],{"class":101},[95,187,188],{"class":105}," Record",[95,190,161],{"class":109},[95,192,193],{"class":123},"string",[95,195,196],{"class":109},", ",[95,198,199],{"class":123},"any",[95,201,177],{"class":109},[95,203,205],{"class":97,"line":204},6,[95,206,207],{"class":109},"}\n",[11,209,210,211,214,215,218,219,222],{},"If the primary channel (",[15,212,213],{},"push",") does not receive a delivery confirmation within 60 seconds, the worker automatically queues an escalation job to the fallback channel (",[15,216,217],{},"email"," or ",[15,220,221],{},"sms",").",[31,224],{},[34,226,228],{"id":227},"_3-worker-implementation-with-bullmq-redis","3. Worker Implementation with BullMQ & Redis",[42,230,232],{"className":89,"code":231,"language":91,"meta":48,"style":48},"import { Worker, Queue } from 'bullmq'\nimport { sendPushNotification } from '.\u002Fproviders\u002Fwebpush'\nimport { sendEmailNotification } from '.\u002Fproviders\u002Fresend'\nimport { sendSmsNotification } from '.\u002Fproviders\u002Ftwilio'\n\nexport const notificationQueue = new Queue('notifications', {\n  connection: { host: 'localhost', port: 6379 }\n})\n\nexport const notificationWorker = new Worker('notifications', async (job) => {\n  const { userId, event, channel, payload, attempt = 1 } = job.data\n  const user = await db.users.findById(userId)\n\n  \u002F\u002F Check user channel preferences and quiet hours\n  if (!user.notificationsEnabled || !user.preferredChannels.includes(channel)) {\n    return { skipped: true, reason: 'user_opt_out' }\n  }\n\n  try {\n    switch (channel) {\n      case 'push':\n        return await sendPushNotification(user.pushSubscription, payload)\n      case 'email':\n        return await sendEmailNotification(user.email, payload)\n      case 'sms':\n        return await sendSmsNotification(user.phone, payload)\n    }\n  } catch (error) {\n    console.error(`Failed sending via ${channel}. Initiating fallback...`, error)\n\n    \u002F\u002F Fallback escalation logic\n    if (channel === 'sms') {\n      await notificationQueue.add('fallback-email', {\n        userId,\n        event,\n        channel: 'email',\n        payload: { ...payload, note: 'Sent via email because SMS failed.' }\n      })\n    }\n    throw error\n  }\n}, {\n  connection: { host: 'localhost', port: 6379 },\n  limiter: {\n    max: 100, \u002F\u002F Maximum 100 notifications dispatched per second to respect provider rate limits\n    duration: 1000\n  }\n})\n",[15,233,234,248,260,272,284,290,319,337,343,348,388,434,456,461,468,497,517,523,528,536,545,557,571,580,593,602,615,621,633,655,660,666,683,702,708,714,726,743,749,754,763,768,774,788,794,808,817,822],{"__ignoreMap":48},[95,235,236,239,242,245],{"class":97,"line":98},[95,237,238],{"class":101},"import",[95,240,241],{"class":109}," { Worker, Queue } ",[95,243,244],{"class":101},"from",[95,246,247],{"class":135}," 'bullmq'\n",[95,249,250,252,255,257],{"class":97,"line":113},[95,251,238],{"class":101},[95,253,254],{"class":109}," { sendPushNotification } ",[95,256,244],{"class":101},[95,258,259],{"class":135}," '.\u002Fproviders\u002Fwebpush'\n",[95,261,262,264,267,269],{"class":97,"line":127},[95,263,238],{"class":101},[95,265,266],{"class":109}," { sendEmailNotification } ",[95,268,244],{"class":101},[95,270,271],{"class":135}," '.\u002Fproviders\u002Fresend'\n",[95,273,274,276,279,281],{"class":97,"line":150},[95,275,238],{"class":101},[95,277,278],{"class":109}," { sendSmsNotification } ",[95,280,244],{"class":101},[95,282,283],{"class":135}," '.\u002Fproviders\u002Ftwilio'\n",[95,285,286],{"class":97,"line":180},[95,287,289],{"emptyLinePlaceholder":288},true,"\n",[95,291,292,295,298,301,304,307,310,313,316],{"class":97,"line":204},[95,293,294],{"class":101},"export",[95,296,297],{"class":101}," const",[95,299,300],{"class":123}," notificationQueue",[95,302,303],{"class":101}," =",[95,305,306],{"class":101}," new",[95,308,309],{"class":105}," Queue",[95,311,312],{"class":109},"(",[95,314,315],{"class":135},"'notifications'",[95,317,318],{"class":109},", {\n",[95,320,322,325,328,331,334],{"class":97,"line":321},7,[95,323,324],{"class":109},"  connection: { host: ",[95,326,327],{"class":135},"'localhost'",[95,329,330],{"class":109},", port: ",[95,332,333],{"class":123},"6379",[95,335,336],{"class":109}," }\n",[95,338,340],{"class":97,"line":339},8,[95,341,342],{"class":109},"})\n",[95,344,346],{"class":97,"line":345},9,[95,347,289],{"emptyLinePlaceholder":288},[95,349,351,353,355,358,360,362,365,367,369,371,374,377,380,383,386],{"class":97,"line":350},10,[95,352,294],{"class":101},[95,354,297],{"class":101},[95,356,357],{"class":123}," notificationWorker",[95,359,303],{"class":101},[95,361,306],{"class":101},[95,363,364],{"class":105}," Worker",[95,366,312],{"class":109},[95,368,315],{"class":135},[95,370,196],{"class":109},[95,372,373],{"class":101},"async",[95,375,376],{"class":109}," (",[95,378,379],{"class":116},"job",[95,381,382],{"class":109},") ",[95,384,385],{"class":101},"=>",[95,387,110],{"class":109},[95,389,391,394,397,400,402,405,407,410,412,415,417,420,422,425,428,431],{"class":97,"line":390},11,[95,392,393],{"class":101},"  const",[95,395,396],{"class":109}," { ",[95,398,399],{"class":123},"userId",[95,401,196],{"class":109},[95,403,404],{"class":123},"event",[95,406,196],{"class":109},[95,408,409],{"class":123},"channel",[95,411,196],{"class":109},[95,413,414],{"class":123},"payload",[95,416,196],{"class":109},[95,418,419],{"class":123},"attempt",[95,421,303],{"class":101},[95,423,424],{"class":123}," 1",[95,426,427],{"class":109}," } ",[95,429,430],{"class":101},"=",[95,432,433],{"class":109}," job.data\n",[95,435,437,439,442,444,447,450,453],{"class":97,"line":436},12,[95,438,393],{"class":101},[95,440,441],{"class":123}," user",[95,443,303],{"class":101},[95,445,446],{"class":101}," await",[95,448,449],{"class":109}," db.users.",[95,451,452],{"class":105},"findById",[95,454,455],{"class":109},"(userId)\n",[95,457,459],{"class":97,"line":458},13,[95,460,289],{"emptyLinePlaceholder":288},[95,462,464],{"class":97,"line":463},14,[95,465,467],{"class":466},"sJ8bj","  \u002F\u002F Check user channel preferences and quiet hours\n",[95,469,471,474,476,479,482,485,488,491,494],{"class":97,"line":470},15,[95,472,473],{"class":101},"  if",[95,475,376],{"class":109},[95,477,478],{"class":101},"!",[95,480,481],{"class":109},"user.notificationsEnabled ",[95,483,484],{"class":101},"||",[95,486,487],{"class":101}," !",[95,489,490],{"class":109},"user.preferredChannels.",[95,492,493],{"class":105},"includes",[95,495,496],{"class":109},"(channel)) {\n",[95,498,500,503,506,509,512,515],{"class":97,"line":499},16,[95,501,502],{"class":101},"    return",[95,504,505],{"class":109}," { skipped: ",[95,507,508],{"class":123},"true",[95,510,511],{"class":109},", reason: ",[95,513,514],{"class":135},"'user_opt_out'",[95,516,336],{"class":109},[95,518,520],{"class":97,"line":519},17,[95,521,522],{"class":109},"  }\n",[95,524,526],{"class":97,"line":525},18,[95,527,289],{"emptyLinePlaceholder":288},[95,529,531,534],{"class":97,"line":530},19,[95,532,533],{"class":101},"  try",[95,535,110],{"class":109},[95,537,539,542],{"class":97,"line":538},20,[95,540,541],{"class":101},"    switch",[95,543,544],{"class":109}," (channel) {\n",[95,546,548,551,554],{"class":97,"line":547},21,[95,549,550],{"class":101},"      case",[95,552,553],{"class":135}," 'push'",[95,555,556],{"class":109},":\n",[95,558,560,563,565,568],{"class":97,"line":559},22,[95,561,562],{"class":101},"        return",[95,564,446],{"class":101},[95,566,567],{"class":105}," sendPushNotification",[95,569,570],{"class":109},"(user.pushSubscription, payload)\n",[95,572,574,576,578],{"class":97,"line":573},23,[95,575,550],{"class":101},[95,577,174],{"class":135},[95,579,556],{"class":109},[95,581,583,585,587,590],{"class":97,"line":582},24,[95,584,562],{"class":101},[95,586,446],{"class":101},[95,588,589],{"class":105}," sendEmailNotification",[95,591,592],{"class":109},"(user.email, payload)\n",[95,594,596,598,600],{"class":97,"line":595},25,[95,597,550],{"class":101},[95,599,169],{"class":135},[95,601,556],{"class":109},[95,603,605,607,609,612],{"class":97,"line":604},26,[95,606,562],{"class":101},[95,608,446],{"class":101},[95,610,611],{"class":105}," sendSmsNotification",[95,613,614],{"class":109},"(user.phone, payload)\n",[95,616,618],{"class":97,"line":617},27,[95,619,620],{"class":109},"    }\n",[95,622,624,627,630],{"class":97,"line":623},28,[95,625,626],{"class":109},"  } ",[95,628,629],{"class":101},"catch",[95,631,632],{"class":109}," (error) {\n",[95,634,636,639,642,644,647,649,652],{"class":97,"line":635},29,[95,637,638],{"class":109},"    console.",[95,640,641],{"class":105},"error",[95,643,312],{"class":109},[95,645,646],{"class":135},"`Failed sending via ${",[95,648,409],{"class":109},[95,650,651],{"class":135},"}. Initiating fallback...`",[95,653,654],{"class":109},", error)\n",[95,656,658],{"class":97,"line":657},30,[95,659,289],{"emptyLinePlaceholder":288},[95,661,663],{"class":97,"line":662},31,[95,664,665],{"class":466},"    \u002F\u002F Fallback escalation logic\n",[95,667,669,672,675,678,680],{"class":97,"line":668},32,[95,670,671],{"class":101},"    if",[95,673,674],{"class":109}," (channel ",[95,676,677],{"class":101},"===",[95,679,169],{"class":135},[95,681,682],{"class":109},") {\n",[95,684,686,689,692,695,697,700],{"class":97,"line":685},33,[95,687,688],{"class":101},"      await",[95,690,691],{"class":109}," notificationQueue.",[95,693,694],{"class":105},"add",[95,696,312],{"class":109},[95,698,699],{"class":135},"'fallback-email'",[95,701,318],{"class":109},[95,703,705],{"class":97,"line":704},34,[95,706,707],{"class":109},"        userId,\n",[95,709,711],{"class":97,"line":710},35,[95,712,713],{"class":109},"        event,\n",[95,715,717,720,723],{"class":97,"line":716},36,[95,718,719],{"class":109},"        channel: ",[95,721,722],{"class":135},"'email'",[95,724,725],{"class":109},",\n",[95,727,729,732,735,738,741],{"class":97,"line":728},37,[95,730,731],{"class":109},"        payload: { ",[95,733,734],{"class":101},"...",[95,736,737],{"class":109},"payload, note: ",[95,739,740],{"class":135},"'Sent via email because SMS failed.'",[95,742,336],{"class":109},[95,744,746],{"class":97,"line":745},38,[95,747,748],{"class":109},"      })\n",[95,750,752],{"class":97,"line":751},39,[95,753,620],{"class":109},[95,755,757,760],{"class":97,"line":756},40,[95,758,759],{"class":101},"    throw",[95,761,762],{"class":109}," error\n",[95,764,766],{"class":97,"line":765},41,[95,767,522],{"class":109},[95,769,771],{"class":97,"line":770},42,[95,772,773],{"class":109},"}, {\n",[95,775,777,779,781,783,785],{"class":97,"line":776},43,[95,778,324],{"class":109},[95,780,327],{"class":135},[95,782,330],{"class":109},[95,784,333],{"class":123},[95,786,787],{"class":109}," },\n",[95,789,791],{"class":97,"line":790},44,[95,792,793],{"class":109},"  limiter: {\n",[95,795,797,800,803,805],{"class":97,"line":796},45,[95,798,799],{"class":109},"    max: ",[95,801,802],{"class":123},"100",[95,804,196],{"class":109},[95,806,807],{"class":466},"\u002F\u002F Maximum 100 notifications dispatched per second to respect provider rate limits\n",[95,809,811,814],{"class":97,"line":810},46,[95,812,813],{"class":109},"    duration: ",[95,815,816],{"class":123},"1000\n",[95,818,820],{"class":97,"line":819},47,[95,821,522],{"class":109},[95,823,825],{"class":97,"line":824},48,[95,826,342],{"class":109},[31,828],{},[34,830,832],{"id":831},"_4-architectural-rules-for-production","4. Architectural Rules for Production",[834,835,836,850,856],"ol",{},[64,837,838,841,842,845,846,849],{},[26,839,840],{},"Idempotency on Notifications",": Add an ",[15,843,844],{},"idempotencyKey"," like ",[15,847,848],{},"order_paid_12345_email",". If your worker restarts, BullMQ will never send duplicate emails to the customer.",[64,851,852,855],{},[26,853,854],{},"Global Unsubscribe & Suppression Lists",": Always check against hard bounces and unsubscribed tags before invoking outbound network calls.",[64,857,858,861],{},[26,859,860],{},"Template Versioning",": Keep email and SMS templates in version control rather than editing HTML in third-party dashboards.",[863,864,865],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":48,"searchDepth":113,"depth":113,"links":867},[868,869,872,873],{"id":36,"depth":113,"text":37},{"id":55,"depth":113,"text":56,"children":870},[871],{"id":85,"depth":127,"text":86},{"id":227,"depth":113,"text":228},{"id":831,"depth":113,"text":832},"2026-09-13","Designing an asynchronous event pipeline that handles user notification preferences, rate limits, and fallback channels (e.g. if SMS fails, send email).","md",false,null,{},"\u002Fblog\u002Fen\u002Fmulti-channel-notification-engine",{"title":5,"description":875},"multi-channel-notification-engine","blog\u002Fen\u002Fmulti-channel-notification-engine",[885,886,887,888,889],"architecture","backend","queues","redis","node","Vkx60MPht3OdgqJwPwD9sWYXPvDaLT2SDAD5JJlHYBE",[892,1719],{"id":893,"title":894,"author":6,"body":895,"date":1708,"description":1709,"extension":876,"featured":877,"image":878,"meta":1710,"navigation":288,"path":1711,"readingTime":204,"seo":1712,"slug":878,"stem":1713,"tags":1714,"__hash__":1718},"blog\u002Fblog\u002Fen\u002Fapi-common-mistakes.md","6 Common API Mistakes (And How to Fix Them)",{"type":8,"value":896,"toc":1699},[897,900,902,906,917,948,969,971,975,982,1064,1069,1189,1191,1195,1198,1258,1261,1263,1267,1270,1403,1410,1412,1416,1419,1591,1594,1596,1600,1603,1637,1644,1646,1650,1653,1696],[11,898,899],{},"Building an API feels straightforward until your first production incident. Most bugs aren't complex — they're the same six mistakes repeated across thousands of codebases. Here's what they are and how to stop making them.",[31,901],{},[34,903,905],{"id":904},"_1-using-the-wrong-http-method","1. Using the Wrong HTTP Method",[11,907,908,909,912,913,916],{},"The most visible mistake. Using ",[15,910,911],{},"GET"," to delete a resource, or ",[15,914,915],{},"POST"," for everything because \"it works.\"",[42,918,922],{"className":919,"code":920,"language":921,"meta":48,"style":48},"language-http shiki shiki-themes github-light github-dark","# ❌ Deleting with GET — catastrophic if a browser prefetches this URL\nGET \u002Fapi\u002Fusers\u002F42\u002Fdelete\n\n# ✅ Correct\nDELETE \u002Fapi\u002Fusers\u002F42\n","http",[15,923,924,929,934,938,943],{"__ignoreMap":48},[95,925,926],{"class":97,"line":98},[95,927,928],{},"# ❌ Deleting with GET — catastrophic if a browser prefetches this URL\n",[95,930,931],{"class":97,"line":113},[95,932,933],{},"GET \u002Fapi\u002Fusers\u002F42\u002Fdelete\n",[95,935,936],{"class":97,"line":127},[95,937,289],{"emptyLinePlaceholder":288},[95,939,940],{"class":97,"line":150},[95,941,942],{},"# ✅ Correct\n",[95,944,945],{"class":97,"line":180},[95,946,947],{},"DELETE \u002Fapi\u002Fusers\u002F42\n",[11,949,950,951,953,954,956,957,960,961,964,965,968],{},"HTTP methods carry semantic meaning that clients, proxies, and caches rely on. ",[15,952,911],{}," must be idempotent and safe (no side effects). ",[15,955,915],{}," creates. ",[15,958,959],{},"PUT","\u002F",[15,962,963],{},"PATCH"," update. ",[15,966,967],{},"DELETE"," removes. Violating these conventions breaks caching, browser history, and every HTTP client that follows the spec.",[31,970],{},[34,972,974],{"id":973},"_2-wrong-status-codes","2. Wrong Status Codes",[11,976,977,978,981],{},"Returning ",[15,979,980],{},"200 OK"," for an error response is the most common mistake in junior APIs.",[42,983,987],{"className":984,"code":985,"language":986,"meta":48,"style":48},"language-json shiki shiki-themes github-light github-dark","\u002F\u002F ❌ Status 200, but it's an error\n{\n  \"success\": false,\n  \"error\": \"User not found\"\n}\n\n\u002F\u002F ✅ Status 404 — the client knows immediately without parsing the body\n{\n  \"error\": \"User not found\",\n  \"code\": \"USER_NOT_FOUND\"\n}\n","json",[15,988,989,994,999,1012,1022,1026,1030,1035,1039,1050,1060],{"__ignoreMap":48},[95,990,991],{"class":97,"line":98},[95,992,993],{"class":466},"\u002F\u002F ❌ Status 200, but it's an error\n",[95,995,996],{"class":97,"line":113},[95,997,998],{"class":109},"{\n",[95,1000,1001,1004,1007,1010],{"class":97,"line":127},[95,1002,1003],{"class":123},"  \"success\"",[95,1005,1006],{"class":109},": ",[95,1008,1009],{"class":123},"false",[95,1011,725],{"class":109},[95,1013,1014,1017,1019],{"class":97,"line":150},[95,1015,1016],{"class":123},"  \"error\"",[95,1018,1006],{"class":109},[95,1020,1021],{"class":135},"\"User not found\"\n",[95,1023,1024],{"class":97,"line":180},[95,1025,207],{"class":109},[95,1027,1028],{"class":97,"line":204},[95,1029,289],{"emptyLinePlaceholder":288},[95,1031,1032],{"class":97,"line":321},[95,1033,1034],{"class":466},"\u002F\u002F ✅ Status 404 — the client knows immediately without parsing the body\n",[95,1036,1037],{"class":97,"line":339},[95,1038,998],{"class":109},[95,1040,1041,1043,1045,1048],{"class":97,"line":345},[95,1042,1016],{"class":123},[95,1044,1006],{"class":109},[95,1046,1047],{"class":135},"\"User not found\"",[95,1049,725],{"class":109},[95,1051,1052,1055,1057],{"class":97,"line":350},[95,1053,1054],{"class":123},"  \"code\"",[95,1056,1006],{"class":109},[95,1058,1059],{"class":135},"\"USER_NOT_FOUND\"\n",[95,1061,1062],{"class":97,"line":390},[95,1063,207],{"class":109},[11,1065,1066],{},[26,1067,1068],{},"The codes that matter most:",[1070,1071,1072,1085],"table",{},[1073,1074,1075],"thead",{},[1076,1077,1078,1082],"tr",{},[1079,1080,1081],"th",{},"Code",[1079,1083,1084],{},"When to use it",[1086,1087,1088,1099,1109,1119,1129,1139,1149,1159,1169,1179],"tbody",{},[1076,1089,1090,1096],{},[1091,1092,1093],"td",{},[15,1094,1095],{},"200",[1091,1097,1098],{},"Success with a body",[1076,1100,1101,1106],{},[1091,1102,1103],{},[15,1104,1105],{},"201",[1091,1107,1108],{},"Resource created (POST)",[1076,1110,1111,1116],{},[1091,1112,1113],{},[15,1114,1115],{},"204",[1091,1117,1118],{},"Success, no body (DELETE)",[1076,1120,1121,1126],{},[1091,1122,1123],{},[15,1124,1125],{},"400",[1091,1127,1128],{},"Invalid input from the client",[1076,1130,1131,1136],{},[1091,1132,1133],{},[15,1134,1135],{},"401",[1091,1137,1138],{},"Not authenticated",[1076,1140,1141,1146],{},[1091,1142,1143],{},[15,1144,1145],{},"403",[1091,1147,1148],{},"Authenticated but not authorized",[1076,1150,1151,1156],{},[1091,1152,1153],{},[15,1154,1155],{},"404",[1091,1157,1158],{},"Resource doesn't exist",[1076,1160,1161,1166],{},[1091,1162,1163],{},[15,1164,1165],{},"422",[1091,1167,1168],{},"Input syntactically valid but semantically wrong",[1076,1170,1171,1176],{},[1091,1172,1173],{},[15,1174,1175],{},"429",[1091,1177,1178],{},"Rate limit exceeded",[1076,1180,1181,1186],{},[1091,1182,1183],{},[15,1184,1185],{},"500",[1091,1187,1188],{},"Server error (never the client's fault)",[31,1190],{},[34,1192,1194],{"id":1193},"_3-trusting-client-input","3. Trusting Client Input",[11,1196,1197],{},"Every value sent by a client is a potential attack vector.",[42,1199,1203],{"className":1200,"code":1201,"language":1202,"meta":48,"style":48},"language-php shiki shiki-themes github-light github-dark","\u002F\u002F ❌ Never do this\n$id = $_GET['id'];\n$query = \"SELECT * FROM users WHERE id = $id\";\n\u002F\u002F SQL injection: ?id=1 OR 1=1\n\n\u002F\u002F ✅ Always validate and sanitize\n$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);\nif (!$id) {\n    return response()->json(['error' => 'Invalid ID'], 400);\n}\n$user = User::findOrFail($id); \u002F\u002F parameterized query\n","php",[15,1204,1205,1210,1215,1220,1225,1229,1234,1239,1244,1249,1253],{"__ignoreMap":48},[95,1206,1207],{"class":97,"line":98},[95,1208,1209],{},"\u002F\u002F ❌ Never do this\n",[95,1211,1212],{"class":97,"line":113},[95,1213,1214],{},"$id = $_GET['id'];\n",[95,1216,1217],{"class":97,"line":127},[95,1218,1219],{},"$query = \"SELECT * FROM users WHERE id = $id\";\n",[95,1221,1222],{"class":97,"line":150},[95,1223,1224],{},"\u002F\u002F SQL injection: ?id=1 OR 1=1\n",[95,1226,1227],{"class":97,"line":180},[95,1228,289],{"emptyLinePlaceholder":288},[95,1230,1231],{"class":97,"line":204},[95,1232,1233],{},"\u002F\u002F ✅ Always validate and sanitize\n",[95,1235,1236],{"class":97,"line":321},[95,1237,1238],{},"$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);\n",[95,1240,1241],{"class":97,"line":339},[95,1242,1243],{},"if (!$id) {\n",[95,1245,1246],{"class":97,"line":345},[95,1247,1248],{},"    return response()->json(['error' => 'Invalid ID'], 400);\n",[95,1250,1251],{"class":97,"line":350},[95,1252,207],{},[95,1254,1255],{"class":97,"line":390},[95,1256,1257],{},"$user = User::findOrFail($id); \u002F\u002F parameterized query\n",[11,1259,1260],{},"Validate: type, format, length, range. Sanitize before any database or filesystem operation. Never trust what arrives in the request body, headers, or query string.",[31,1262],{},[34,1264,1266],{"id":1265},"_4-leaking-sensitive-data","4. Leaking Sensitive Data",[11,1268,1269],{},"Returning the full database object without filtering is an invitation to a data breach.",[42,1271,1273],{"className":984,"code":1272,"language":986,"meta":48,"style":48},"\u002F\u002F ❌ Returning the raw User model\n{\n  \"id\": 42,\n  \"email\": \"user@example.com\",\n  \"password\": \"$2b$12$...\",\n  \"stripe_customer_id\": \"cus_xxx\",\n  \"internal_notes\": \"flagged for review\",\n  \"created_at\": \"2024-01-01\"\n}\n\n\u002F\u002F ✅ Return only what the client actually needs\n{\n  \"id\": 42,\n  \"email\": \"user@example.com\",\n  \"created_at\": \"2024-01-01\"\n}\n",[15,1274,1275,1280,1284,1296,1308,1320,1332,1344,1354,1358,1362,1367,1371,1381,1391,1399],{"__ignoreMap":48},[95,1276,1277],{"class":97,"line":98},[95,1278,1279],{"class":466},"\u002F\u002F ❌ Returning the raw User model\n",[95,1281,1282],{"class":97,"line":113},[95,1283,998],{"class":109},[95,1285,1286,1289,1291,1294],{"class":97,"line":127},[95,1287,1288],{"class":123},"  \"id\"",[95,1290,1006],{"class":109},[95,1292,1293],{"class":123},"42",[95,1295,725],{"class":109},[95,1297,1298,1301,1303,1306],{"class":97,"line":150},[95,1299,1300],{"class":123},"  \"email\"",[95,1302,1006],{"class":109},[95,1304,1305],{"class":135},"\"user@example.com\"",[95,1307,725],{"class":109},[95,1309,1310,1313,1315,1318],{"class":97,"line":180},[95,1311,1312],{"class":123},"  \"password\"",[95,1314,1006],{"class":109},[95,1316,1317],{"class":135},"\"$2b$12$...\"",[95,1319,725],{"class":109},[95,1321,1322,1325,1327,1330],{"class":97,"line":204},[95,1323,1324],{"class":123},"  \"stripe_customer_id\"",[95,1326,1006],{"class":109},[95,1328,1329],{"class":135},"\"cus_xxx\"",[95,1331,725],{"class":109},[95,1333,1334,1337,1339,1342],{"class":97,"line":321},[95,1335,1336],{"class":123},"  \"internal_notes\"",[95,1338,1006],{"class":109},[95,1340,1341],{"class":135},"\"flagged for review\"",[95,1343,725],{"class":109},[95,1345,1346,1349,1351],{"class":97,"line":339},[95,1347,1348],{"class":123},"  \"created_at\"",[95,1350,1006],{"class":109},[95,1352,1353],{"class":135},"\"2024-01-01\"\n",[95,1355,1356],{"class":97,"line":345},[95,1357,207],{"class":109},[95,1359,1360],{"class":97,"line":350},[95,1361,289],{"emptyLinePlaceholder":288},[95,1363,1364],{"class":97,"line":390},[95,1365,1366],{"class":466},"\u002F\u002F ✅ Return only what the client actually needs\n",[95,1368,1369],{"class":97,"line":436},[95,1370,998],{"class":109},[95,1372,1373,1375,1377,1379],{"class":97,"line":458},[95,1374,1288],{"class":123},[95,1376,1006],{"class":109},[95,1378,1293],{"class":123},[95,1380,725],{"class":109},[95,1382,1383,1385,1387,1389],{"class":97,"line":463},[95,1384,1300],{"class":123},[95,1386,1006],{"class":109},[95,1388,1305],{"class":135},[95,1390,725],{"class":109},[95,1392,1393,1395,1397],{"class":97,"line":470},[95,1394,1348],{"class":123},[95,1396,1006],{"class":109},[95,1398,1353],{"class":135},[95,1400,1401],{"class":97,"line":499},[95,1402,207],{"class":109},[11,1404,1405,1406,1409],{},"Use serializers, DTOs, or API Resources (Laravel) to explicitly define what gets exposed. The rule: ",[26,1407,1408],{},"opt-in, not opt-out."," Start with nothing, add what's needed.",[31,1411],{},[34,1413,1415],{"id":1414},"_5-inconsistent-error-format","5. Inconsistent Error Format",[11,1417,1418],{},"When every endpoint returns errors differently, every client has to handle every format.",[42,1420,1422],{"className":984,"code":1421,"language":986,"meta":48,"style":48},"\u002F\u002F ❌ Endpoint A\n{ \"message\": \"Not found\" }\n\n\u002F\u002F ❌ Endpoint B\n{ \"error\": true, \"msg\": \"Validation failed\", \"fields\": [\"email\"] }\n\n\u002F\u002F ❌ Endpoint C\n\"Internal server error\"\n\n\u002F\u002F ✅ One format, everywhere\n{\n  \"error\": {\n    \"code\": \"VALIDATION_FAILED\",\n    \"message\": \"The request contains invalid fields.\",\n    \"details\": [\n      { \"field\": \"email\", \"issue\": \"Invalid email format\" }\n    ]\n  }\n}\n",[15,1423,1424,1429,1444,1448,1453,1488,1492,1497,1502,1506,1511,1515,1522,1534,1546,1554,1578,1583,1587],{"__ignoreMap":48},[95,1425,1426],{"class":97,"line":98},[95,1427,1428],{"class":466},"\u002F\u002F ❌ Endpoint A\n",[95,1430,1431,1434,1437,1439,1442],{"class":97,"line":113},[95,1432,1433],{"class":109},"{ ",[95,1435,1436],{"class":123},"\"message\"",[95,1438,1006],{"class":109},[95,1440,1441],{"class":135},"\"Not found\"",[95,1443,336],{"class":109},[95,1445,1446],{"class":97,"line":127},[95,1447,289],{"emptyLinePlaceholder":288},[95,1449,1450],{"class":97,"line":150},[95,1451,1452],{"class":466},"\u002F\u002F ❌ Endpoint B\n",[95,1454,1455,1457,1460,1462,1464,1466,1469,1471,1474,1476,1479,1482,1485],{"class":97,"line":180},[95,1456,1433],{"class":109},[95,1458,1459],{"class":123},"\"error\"",[95,1461,1006],{"class":109},[95,1463,508],{"class":123},[95,1465,196],{"class":109},[95,1467,1468],{"class":123},"\"msg\"",[95,1470,1006],{"class":109},[95,1472,1473],{"class":135},"\"Validation failed\"",[95,1475,196],{"class":109},[95,1477,1478],{"class":123},"\"fields\"",[95,1480,1481],{"class":109},": [",[95,1483,1484],{"class":135},"\"email\"",[95,1486,1487],{"class":109},"] }\n",[95,1489,1490],{"class":97,"line":204},[95,1491,289],{"emptyLinePlaceholder":288},[95,1493,1494],{"class":97,"line":321},[95,1495,1496],{"class":466},"\u002F\u002F ❌ Endpoint C\n",[95,1498,1499],{"class":97,"line":339},[95,1500,1501],{"class":135},"\"Internal server error\"\n",[95,1503,1504],{"class":97,"line":345},[95,1505,289],{"emptyLinePlaceholder":288},[95,1507,1508],{"class":97,"line":350},[95,1509,1510],{"class":466},"\u002F\u002F ✅ One format, everywhere\n",[95,1512,1513],{"class":97,"line":390},[95,1514,998],{"class":109},[95,1516,1517,1519],{"class":97,"line":436},[95,1518,1016],{"class":123},[95,1520,1521],{"class":109},": {\n",[95,1523,1524,1527,1529,1532],{"class":97,"line":458},[95,1525,1526],{"class":123},"    \"code\"",[95,1528,1006],{"class":109},[95,1530,1531],{"class":135},"\"VALIDATION_FAILED\"",[95,1533,725],{"class":109},[95,1535,1536,1539,1541,1544],{"class":97,"line":463},[95,1537,1538],{"class":123},"    \"message\"",[95,1540,1006],{"class":109},[95,1542,1543],{"class":135},"\"The request contains invalid fields.\"",[95,1545,725],{"class":109},[95,1547,1548,1551],{"class":97,"line":470},[95,1549,1550],{"class":123},"    \"details\"",[95,1552,1553],{"class":109},": [\n",[95,1555,1556,1559,1562,1564,1566,1568,1571,1573,1576],{"class":97,"line":499},[95,1557,1558],{"class":109},"      { ",[95,1560,1561],{"class":123},"\"field\"",[95,1563,1006],{"class":109},[95,1565,1484],{"class":135},[95,1567,196],{"class":109},[95,1569,1570],{"class":123},"\"issue\"",[95,1572,1006],{"class":109},[95,1574,1575],{"class":135},"\"Invalid email format\"",[95,1577,336],{"class":109},[95,1579,1580],{"class":97,"line":519},[95,1581,1582],{"class":109},"    ]\n",[95,1584,1585],{"class":97,"line":525},[95,1586,522],{"class":109},[95,1588,1589],{"class":97,"line":530},[95,1590,207],{"class":109},[11,1592,1593],{},"Define your error envelope once. Apply it everywhere via a global exception handler. Clients — including your own frontend — will thank you.",[31,1595],{},[34,1597,1599],{"id":1598},"_6-no-api-versioning","6. No API Versioning",[11,1601,1602],{},"Changing an endpoint response breaks every client using it. Without versioning, you can never evolve the API.",[42,1604,1606],{"className":919,"code":1605,"language":921,"meta":48,"style":48},"# ❌ No version — any breaking change affects all clients immediately\nGET \u002Fapi\u002Fusers\n\n# ✅ Versioned — v1 stays stable, v2 introduces breaking changes safely\nGET \u002Fapi\u002Fv1\u002Fusers\nGET \u002Fapi\u002Fv2\u002Fusers\n",[15,1607,1608,1613,1618,1622,1627,1632],{"__ignoreMap":48},[95,1609,1610],{"class":97,"line":98},[95,1611,1612],{},"# ❌ No version — any breaking change affects all clients immediately\n",[95,1614,1615],{"class":97,"line":113},[95,1616,1617],{},"GET \u002Fapi\u002Fusers\n",[95,1619,1620],{"class":97,"line":127},[95,1621,289],{"emptyLinePlaceholder":288},[95,1623,1624],{"class":97,"line":150},[95,1625,1626],{},"# ✅ Versioned — v1 stays stable, v2 introduces breaking changes safely\n",[95,1628,1629],{"class":97,"line":180},[95,1630,1631],{},"GET \u002Fapi\u002Fv1\u002Fusers\n",[95,1633,1634],{"class":97,"line":204},[95,1635,1636],{},"GET \u002Fapi\u002Fv2\u002Fusers\n",[11,1638,1639,1640,1643],{},"Version from day one, even if you only have v1. The cost of adding ",[15,1641,1642],{},"\u002Fv1\u002F"," later — after clients are in production — is much higher than the cost of including it from the start.",[31,1645],{},[34,1647,1649],{"id":1648},"the-takeaway","The Takeaway",[11,1651,1652],{},"None of these mistakes require advanced knowledge to fix. They require discipline: a checklist before every endpoint ships.",[61,1654,1657,1666,1672,1678,1684,1690],{"className":1655},[1656],"contains-task-list",[64,1658,1661,1665],{"className":1659},[1660],"task-list-item",[1662,1663],"input",{"disabled":288,"type":1664},"checkbox"," HTTP method matches the operation's semantics",[64,1667,1669,1671],{"className":1668},[1660],[1662,1670],{"disabled":288,"type":1664}," Status code is accurate",[64,1673,1675,1677],{"className":1674},[1660],[1662,1676],{"disabled":288,"type":1664}," All input is validated before use",[64,1679,1681,1683],{"className":1680},[1660],[1662,1682],{"disabled":288,"type":1664}," Response only exposes necessary fields",[64,1685,1687,1689],{"className":1686},[1660],[1662,1688],{"disabled":288,"type":1664}," Errors follow the global format",[64,1691,1693,1695],{"className":1692},[1660],[1662,1694],{"disabled":288,"type":1664}," URL includes an API version",[863,1697,1698],{},"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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":48,"searchDepth":113,"depth":113,"links":1700},[1701,1702,1703,1704,1705,1706,1707],{"id":904,"depth":113,"text":905},{"id":973,"depth":113,"text":974},{"id":1193,"depth":113,"text":1194},{"id":1265,"depth":113,"text":1266},{"id":1414,"depth":113,"text":1415},{"id":1598,"depth":113,"text":1599},{"id":1648,"depth":113,"text":1649},"2026-06-04","From wrong HTTP methods to leaking sensitive data — the errors most developers make when building their first API, and the concrete fixes.",{},"\u002Fblog\u002Fen\u002Fapi-common-mistakes",{"title":894,"description":1709},"blog\u002Fen\u002Fapi-common-mistakes",[1715,886,1716,1717],"api","rest","best-practices","z2NMprz3c9WpXKotgDMzEF9WM_LLLolyFqidBZu4I6s",{"id":1720,"title":1721,"author":6,"body":1722,"date":2336,"description":2337,"extension":876,"featured":877,"image":878,"meta":2338,"navigation":288,"path":2339,"readingTime":204,"seo":2340,"slug":2341,"stem":2342,"tags":2343,"__hash__":2346},"blog\u002Fblog\u002Fen\u002Fapi-rate-limiting-redis.md","API Rate Limiting in Practice: Token Bucket vs. Sliding Window with Redis",{"type":8,"value":1723,"toc":2327},[1724,1727,1730,1741,1743,1747,1751,1758,1762,1765,1773,1777,1780,1782,1786,1793,2255,2257,2261,2264,2321,2324],[11,1725,1726],{},"Without API rate limiting, a single rogue script or misconfigured webhook client can fire 10,000 requests per second at your login or checkout endpoint, exhausting your database connection pool and taking your entire application down.",[11,1728,1729],{},"Rate limiting is the first line of defense against bot attacks, credential stuffing, and API resource abuse. But which algorithm should you implement?",[11,1731,1732,1733,1736,1737,1740],{},"In this article, we compare the two industry-standard algorithms — ",[26,1734,1735],{},"Token Bucket"," and ",[26,1738,1739],{},"Sliding Window Log"," — and show how to implement a high-speed sliding window rate limiter in Redis.",[31,1742],{},[34,1744,1746],{"id":1745},"_1-comparing-the-algorithms","1. Comparing the Algorithms",[83,1748,1750],{"id":1749},"a-fixed-window-counter-the-flawed-default","A. Fixed Window Counter (The Flawed Default)",[11,1752,1753,1754,1757],{},"Divides time into fixed buckets (e.g. 10:00 to 10:01). If a user can make 100 requests per minute, they can send 100 requests at 10:00:59, and another 100 requests at 10:01:01. That means ",[26,1755,1756],{},"200 requests in 2 seconds"," — double the intended burst limit!",[83,1759,1761],{"id":1760},"b-token-bucket","B. Token Bucket",[11,1763,1764],{},"A bucket holds a fixed number of tokens (burst capacity). Tokens are continuously replenished at a constant rate. Each incoming request consumes one token.",[61,1766,1767],{},[64,1768,1769,1772],{},[26,1770,1771],{},"Great for",": APIs that want to allow legitimate temporary traffic spikes while capping sustained throughput.",[83,1774,1776],{"id":1775},"c-sliding-window-counter-the-production-standard","C. Sliding Window Counter (The Production Standard)",[11,1778,1779],{},"Combines the low memory usage of fixed windows with the smoothness of a rolling window. It calculates the request rate by weighting the request count of the previous window and the current window.",[31,1781],{},[34,1783,1785],{"id":1784},"_2-sliding-window-rate-limiter-with-redis-sorted-sets","2. Sliding Window Rate Limiter with Redis Sorted Sets",[11,1787,1788,1789,1792],{},"Redis Sorted Sets (",[15,1790,1791],{},"ZSET",") allow us to track requests by timestamp with microsecond accuracy:",[42,1794,1796],{"className":89,"code":1795,"language":91,"meta":48,"style":48},"import Redis from 'ioredis'\n\nconst redis = new Redis(process.env.REDIS_URL!)\n\nexport async function checkRateLimit(identifier: string, limit = 60, windowInSeconds = 60) {\n  const now = Date.now()\n  const windowStart = now - (windowInSeconds * 1000)\n  const key = `ratelimit:${identifier}`\n\n  \u002F\u002F Atomic Redis Pipeline (Runs in a single round-trip)\n  const multi = redis.multi()\n  \u002F\u002F 1. Remove all request timestamps older than the active window\n  multi.zremrangebyscore(key, 0, windowStart)\n  \u002F\u002F 2. Count requests currently in the active window\n  multi.zcard(key)\n  \u002F\u002F 3. Add the current timestamp as a new request\n  multi.zadd(key, now, `${now}-${Math.random()}`)\n  \u002F\u002F 4. Set key expiration to auto-clean idle users\n  multi.expire(key, windowInSeconds)\n\n  const results = await multi.exec()\n  const currentCount = (results?.[1]?.[1] as number) || 0\n\n  if (currentCount >= limit) {\n    return {\n      allowed: false,\n      remaining: 0,\n      retryAfter: Math.ceil(windowInSeconds - ((now - windowStart) \u002F 1000))\n    }\n  }\n\n  return {\n    allowed: true,\n    remaining: limit - currentCount - 1,\n    retryAfter: 0\n  }\n}\n",[15,1797,1798,1810,1814,1840,1844,1888,1906,1932,1949,1953,1958,1975,1980,1997,2002,2012,2017,2052,2057,2067,2071,2090,2126,2130,2143,2149,2158,2167,2195,2199,2203,2207,2214,2223,2239,2247,2251],{"__ignoreMap":48},[95,1799,1800,1802,1805,1807],{"class":97,"line":98},[95,1801,238],{"class":101},[95,1803,1804],{"class":109}," Redis ",[95,1806,244],{"class":101},[95,1808,1809],{"class":135}," 'ioredis'\n",[95,1811,1812],{"class":97,"line":113},[95,1813,289],{"emptyLinePlaceholder":288},[95,1815,1816,1819,1822,1824,1826,1829,1832,1835,1837],{"class":97,"line":127},[95,1817,1818],{"class":101},"const",[95,1820,1821],{"class":123}," redis",[95,1823,303],{"class":101},[95,1825,306],{"class":101},[95,1827,1828],{"class":105}," Redis",[95,1830,1831],{"class":109},"(process.env.",[95,1833,1834],{"class":123},"REDIS_URL",[95,1836,478],{"class":101},[95,1838,1839],{"class":109},")\n",[95,1841,1842],{"class":97,"line":150},[95,1843,289],{"emptyLinePlaceholder":288},[95,1845,1846,1848,1851,1854,1857,1859,1862,1864,1867,1869,1872,1874,1877,1879,1882,1884,1886],{"class":97,"line":180},[95,1847,294],{"class":101},[95,1849,1850],{"class":101}," async",[95,1852,1853],{"class":101}," function",[95,1855,1856],{"class":105}," checkRateLimit",[95,1858,312],{"class":109},[95,1860,1861],{"class":116},"identifier",[95,1863,120],{"class":101},[95,1865,1866],{"class":123}," string",[95,1868,196],{"class":109},[95,1870,1871],{"class":116},"limit",[95,1873,303],{"class":101},[95,1875,1876],{"class":123}," 60",[95,1878,196],{"class":109},[95,1880,1881],{"class":116},"windowInSeconds",[95,1883,303],{"class":101},[95,1885,1876],{"class":123},[95,1887,682],{"class":109},[95,1889,1890,1892,1895,1897,1900,1903],{"class":97,"line":204},[95,1891,393],{"class":101},[95,1893,1894],{"class":123}," now",[95,1896,303],{"class":101},[95,1898,1899],{"class":109}," Date.",[95,1901,1902],{"class":105},"now",[95,1904,1905],{"class":109},"()\n",[95,1907,1908,1910,1913,1915,1918,1921,1924,1927,1930],{"class":97,"line":321},[95,1909,393],{"class":101},[95,1911,1912],{"class":123}," windowStart",[95,1914,303],{"class":101},[95,1916,1917],{"class":109}," now ",[95,1919,1920],{"class":101},"-",[95,1922,1923],{"class":109}," (windowInSeconds ",[95,1925,1926],{"class":101},"*",[95,1928,1929],{"class":123}," 1000",[95,1931,1839],{"class":109},[95,1933,1934,1936,1939,1941,1944,1946],{"class":97,"line":339},[95,1935,393],{"class":101},[95,1937,1938],{"class":123}," key",[95,1940,303],{"class":101},[95,1942,1943],{"class":135}," `ratelimit:${",[95,1945,1861],{"class":109},[95,1947,1948],{"class":135},"}`\n",[95,1950,1951],{"class":97,"line":345},[95,1952,289],{"emptyLinePlaceholder":288},[95,1954,1955],{"class":97,"line":350},[95,1956,1957],{"class":466},"  \u002F\u002F Atomic Redis Pipeline (Runs in a single round-trip)\n",[95,1959,1960,1962,1965,1967,1970,1973],{"class":97,"line":390},[95,1961,393],{"class":101},[95,1963,1964],{"class":123}," multi",[95,1966,303],{"class":101},[95,1968,1969],{"class":109}," redis.",[95,1971,1972],{"class":105},"multi",[95,1974,1905],{"class":109},[95,1976,1977],{"class":97,"line":436},[95,1978,1979],{"class":466},"  \u002F\u002F 1. Remove all request timestamps older than the active window\n",[95,1981,1982,1985,1988,1991,1994],{"class":97,"line":458},[95,1983,1984],{"class":109},"  multi.",[95,1986,1987],{"class":105},"zremrangebyscore",[95,1989,1990],{"class":109},"(key, ",[95,1992,1993],{"class":123},"0",[95,1995,1996],{"class":109},", windowStart)\n",[95,1998,1999],{"class":97,"line":463},[95,2000,2001],{"class":466},"  \u002F\u002F 2. Count requests currently in the active window\n",[95,2003,2004,2006,2009],{"class":97,"line":470},[95,2005,1984],{"class":109},[95,2007,2008],{"class":105},"zcard",[95,2010,2011],{"class":109},"(key)\n",[95,2013,2014],{"class":97,"line":499},[95,2015,2016],{"class":466},"  \u002F\u002F 3. Add the current timestamp as a new request\n",[95,2018,2019,2021,2024,2027,2030,2032,2035,2038,2041,2044,2047,2050],{"class":97,"line":519},[95,2020,1984],{"class":109},[95,2022,2023],{"class":105},"zadd",[95,2025,2026],{"class":109},"(key, now, ",[95,2028,2029],{"class":135},"`${",[95,2031,1902],{"class":109},[95,2033,2034],{"class":135},"}-${",[95,2036,2037],{"class":109},"Math",[95,2039,2040],{"class":135},".",[95,2042,2043],{"class":105},"random",[95,2045,2046],{"class":135},"()",[95,2048,2049],{"class":135},"}`",[95,2051,1839],{"class":109},[95,2053,2054],{"class":97,"line":525},[95,2055,2056],{"class":466},"  \u002F\u002F 4. Set key expiration to auto-clean idle users\n",[95,2058,2059,2061,2064],{"class":97,"line":530},[95,2060,1984],{"class":109},[95,2062,2063],{"class":105},"expire",[95,2065,2066],{"class":109},"(key, windowInSeconds)\n",[95,2068,2069],{"class":97,"line":538},[95,2070,289],{"emptyLinePlaceholder":288},[95,2072,2073,2075,2078,2080,2082,2085,2088],{"class":97,"line":547},[95,2074,393],{"class":101},[95,2076,2077],{"class":123}," results",[95,2079,303],{"class":101},[95,2081,446],{"class":101},[95,2083,2084],{"class":109}," multi.",[95,2086,2087],{"class":105},"exec",[95,2089,1905],{"class":109},[95,2091,2092,2094,2097,2099,2102,2105,2108,2110,2113,2116,2119,2121,2123],{"class":97,"line":559},[95,2093,393],{"class":101},[95,2095,2096],{"class":123}," currentCount",[95,2098,303],{"class":101},[95,2100,2101],{"class":109}," (results?.[",[95,2103,2104],{"class":123},"1",[95,2106,2107],{"class":109},"]?.[",[95,2109,2104],{"class":123},[95,2111,2112],{"class":109},"] ",[95,2114,2115],{"class":101},"as",[95,2117,2118],{"class":123}," number",[95,2120,382],{"class":109},[95,2122,484],{"class":101},[95,2124,2125],{"class":123}," 0\n",[95,2127,2128],{"class":97,"line":573},[95,2129,289],{"emptyLinePlaceholder":288},[95,2131,2132,2134,2137,2140],{"class":97,"line":582},[95,2133,473],{"class":101},[95,2135,2136],{"class":109}," (currentCount ",[95,2138,2139],{"class":101},">=",[95,2141,2142],{"class":109}," limit) {\n",[95,2144,2145,2147],{"class":97,"line":595},[95,2146,502],{"class":101},[95,2148,110],{"class":109},[95,2150,2151,2154,2156],{"class":97,"line":604},[95,2152,2153],{"class":109},"      allowed: ",[95,2155,1009],{"class":123},[95,2157,725],{"class":109},[95,2159,2160,2163,2165],{"class":97,"line":617},[95,2161,2162],{"class":109},"      remaining: ",[95,2164,1993],{"class":123},[95,2166,725],{"class":109},[95,2168,2169,2172,2175,2178,2180,2183,2185,2188,2190,2192],{"class":97,"line":623},[95,2170,2171],{"class":109},"      retryAfter: Math.",[95,2173,2174],{"class":105},"ceil",[95,2176,2177],{"class":109},"(windowInSeconds ",[95,2179,1920],{"class":101},[95,2181,2182],{"class":109}," ((now ",[95,2184,1920],{"class":101},[95,2186,2187],{"class":109}," windowStart) ",[95,2189,960],{"class":101},[95,2191,1929],{"class":123},[95,2193,2194],{"class":109},"))\n",[95,2196,2197],{"class":97,"line":635},[95,2198,620],{"class":109},[95,2200,2201],{"class":97,"line":657},[95,2202,522],{"class":109},[95,2204,2205],{"class":97,"line":662},[95,2206,289],{"emptyLinePlaceholder":288},[95,2208,2209,2212],{"class":97,"line":668},[95,2210,2211],{"class":101},"  return",[95,2213,110],{"class":109},[95,2215,2216,2219,2221],{"class":97,"line":685},[95,2217,2218],{"class":109},"    allowed: ",[95,2220,508],{"class":123},[95,2222,725],{"class":109},[95,2224,2225,2228,2230,2233,2235,2237],{"class":97,"line":704},[95,2226,2227],{"class":109},"    remaining: limit ",[95,2229,1920],{"class":101},[95,2231,2232],{"class":109}," currentCount ",[95,2234,1920],{"class":101},[95,2236,424],{"class":123},[95,2238,725],{"class":109},[95,2240,2241,2244],{"class":97,"line":710},[95,2242,2243],{"class":109},"    retryAfter: ",[95,2245,2246],{"class":123},"0\n",[95,2248,2249],{"class":97,"line":716},[95,2250,522],{"class":109},[95,2252,2253],{"class":97,"line":728},[95,2254,207],{"class":109},[31,2256],{},[34,2258,2260],{"id":2259},"_3-returning-standard-http-rate-limit-headers","3. Returning Standard HTTP Rate Limit Headers",[11,2262,2263],{},"Whenever your server answers a request, communicate rate limit states in the response headers (following IETF draft standards):",[42,2265,2267],{"className":919,"code":2266,"language":921,"meta":48,"style":48},"HTTP\u002F1.1 429 Too Many Requests\nRetry-After: 32\nX-RateLimit-Limit: 60\nX-RateLimit-Remaining: 0\nX-RateLimit-Reset: 1773489240\nContent-Type: application\u002Fjson\n\n{\n  \"error\": \"Too Many Requests\",\n  \"message\": \"Rate limit exceeded. Please retry in 32 seconds.\"\n}\n",[15,2268,2269,2274,2279,2284,2289,2294,2299,2303,2307,2312,2317],{"__ignoreMap":48},[95,2270,2271],{"class":97,"line":98},[95,2272,2273],{},"HTTP\u002F1.1 429 Too Many Requests\n",[95,2275,2276],{"class":97,"line":113},[95,2277,2278],{},"Retry-After: 32\n",[95,2280,2281],{"class":97,"line":127},[95,2282,2283],{},"X-RateLimit-Limit: 60\n",[95,2285,2286],{"class":97,"line":150},[95,2287,2288],{},"X-RateLimit-Remaining: 0\n",[95,2290,2291],{"class":97,"line":180},[95,2292,2293],{},"X-RateLimit-Reset: 1773489240\n",[95,2295,2296],{"class":97,"line":204},[95,2297,2298],{},"Content-Type: application\u002Fjson\n",[95,2300,2301],{"class":97,"line":321},[95,2302,289],{"emptyLinePlaceholder":288},[95,2304,2305],{"class":97,"line":339},[95,2306,998],{},[95,2308,2309],{"class":97,"line":345},[95,2310,2311],{},"  \"error\": \"Too Many Requests\",\n",[95,2313,2314],{"class":97,"line":350},[95,2315,2316],{},"  \"message\": \"Rate limit exceeded. Please retry in 32 seconds.\"\n",[95,2318,2319],{"class":97,"line":390},[95,2320,207],{},[11,2322,2323],{},"This ensures honest third-party API clients and mobile SDKs can automatically back off and retry without overwhelming your infrastructure.",[863,2325,2326],{},"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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":48,"searchDepth":113,"depth":113,"links":2328},[2329,2334,2335],{"id":1745,"depth":113,"text":1746,"children":2330},[2331,2332,2333],{"id":1749,"depth":127,"text":1750},{"id":1760,"depth":127,"text":1761},{"id":1775,"depth":127,"text":1776},{"id":1784,"depth":113,"text":1785},{"id":2259,"depth":113,"text":2260},"2026-09-14","Comparing rate-limiting algorithms to protect your backend against scrapers, credential stuffing, and bot spam with minimal latency overhead.",{},"\u002Fblog\u002Fen\u002Fapi-rate-limiting-redis",{"title":1721,"description":2337},"api-rate-limiting-redis","blog\u002Fen\u002Fapi-rate-limiting-redis",[1715,2344,888,886,2345],"security","performance","KXTsuQOwj895dkbCDObhyO_wOncRKtB2ufJbjc-AUyc",1792236100133]