[{"data":1,"prerenderedAt":9689},["ShallowReactive",2],{"blog-paginated-count":3,"blog-paginated-11":4,"blog-paginated-cats":9044},640,[5,244,2168,2576,3068,3328,3878,4159,6497,6817,7062,7525,7785,8507,8814],{"id":6,"title":7,"author":8,"body":11,"category":224,"date":225,"description":226,"extension":227,"featured":228,"image":229,"keywords":230,"meta":233,"navigation":234,"path":235,"readTime":236,"seo":237,"stem":238,"tags":239,"__hash__":243},"blog/blog/pricing-software-projects.md","Pricing Custom Software Projects: The Framework That Works",{"name":9,"bio":10},"James Ross Jr.","Strategic Systems Architect & Enterprise Software Developer",{"type":12,"value":13,"toc":212},"minimark",[14,19,23,26,29,32,35,39,42,45,48,50,54,61,64,70,73,79,82,84,88,91,99,102,105,107,111,114,120,126,132,138,144,150,152,156,159,162,165,167,178,180,184],[15,16,18],"h2",{"id":17},"the-problem-with-software-pricing","The Problem With Software Pricing",[20,21,22],"p",{},"Custom software projects are chronically mispriced. Sometimes they're underpriced by developers who want to win the work and figure they'll sort out the budget later. Sometimes they're overpriced by firms that throw a multiplier on an estimate and hope the client doesn't push back. In both cases, the project suffers.",[20,24,25],{},"Underpriced projects create resentment. The developer resents doing work they're not being compensated for. The client resents scope constraints they didn't expect. The relationship deteriorates, and the product suffers.",[20,27,28],{},"Overpriced projects either don't get built or get built with a client who felt they were taken advantage of — and who won't be a reference or a repeat customer.",[20,30,31],{},"Good pricing is actually a technical skill. Here's the framework I've developed after pricing dozens of projects at every scale.",[33,34],"hr",{},[15,36,38],{"id":37},"start-with-a-discovery-phase-always","Start With a Discovery Phase — Always",[20,40,41],{},"If you're quoting a custom software project based on a one-hour conversation and a two-paragraph brief, you're guessing. I don't care how experienced you are. You don't know enough yet.",[20,43,44],{},"The discovery phase is a paid engagement — typically a fixed-fee project that produces a written technical specification, an architecture recommendation, a risk assessment, and a detailed scope document. For a project that will eventually cost $50,000 to $500,000, spending $3,000 to $10,000 to understand what you're actually building is not expensive. It's insurance.",[20,46,47],{},"For clients who are new to custom software development, I frame it this way: the discovery phase answers three questions. What exactly are we building? How are we going to build it? What can go wrong, and what's the plan if it does? Any contractor who skips those questions is selling you certainty they don't have.",[33,49],{},[15,51,53],{"id":52},"the-three-pricing-models-and-when-to-use-each","The Three Pricing Models and When to Use Each",[20,55,56,60],{},[57,58,59],"strong",{},"Fixed-price contracts."," You define a specific scope, deliver it, and get paid a fixed amount. Great for: well-defined projects with stable requirements, clients who need budget certainty, and projects where you've done something very similar before. Terrible for: anything with unclear requirements, significant third-party dependencies, or a client who expects to \"figure it out as we go.\"",[20,62,63],{},"If you're doing fixed-price, you need to charge enough to cover your estimate plus a contingency buffer. I use 20-30% for straightforward projects and 40-50% for anything with integration complexity or ambiguous requirements. Clients push back on this. Explain that the contingency doesn't go in your pocket if nothing goes wrong — it's your mutual insurance against the cost of uncertainty.",[20,65,66,69],{},[57,67,68],{},"Time and materials (T&M)."," You charge an hourly or daily rate for the actual time spent. Great for: ongoing development, evolving requirements, and clients who want flexibility. The risk for the client is open-ended cost. The risk for you is a client who second-guesses every hour and eventually disputes the invoice.",[20,71,72],{},"Mitigate this with weekly check-ins, clear documentation of what was built in each period, and a time-tracking discipline that makes the work transparent. Clients accept T&M costs more readily when they can see what they're getting.",[20,74,75,78],{},[57,76,77],{},"Value-based pricing."," You price based on the value the software will create for the client, not the cost of building it. If your e-commerce platform rebuild will generate $2M in additional annual revenue, charging $150,000 is not expensive — it's a 7-month payback period. Great for: experienced clients with quantifiable business outcomes, and developers with the confidence to have the value conversation.",[20,80,81],{},"This is the highest leverage model if you can execute it, but it requires understanding the client's business well enough to credibly make the value case.",[33,83],{},[15,85,87],{"id":86},"how-i-build-an-estimate","How I Build an Estimate",[20,89,90],{},"Every estimate starts with a work breakdown structure (WBS) — a hierarchical decomposition of every feature into its components. Not \"user authentication\" as a line item. \"Email/password login, social auth (Google, Apple), password reset flow, session management, JWT token refresh\" as separate components, each with its own estimate.",[20,92,93,94,98],{},"For each component, I estimate three numbers: best case, most likely, and worst case. Then I apply three-point estimation: ",[95,96,97],"code",{},"(Best + 4×MostLikely + Worst) / 6",". This is a weighted average that accounts for the skewed distribution of software estimates — things almost never finish faster than expected, but they regularly take twice as long.",[20,100,101],{},"I estimate in hours, then convert to cost at my blended rate. Then I add discovery overhead (which should already be done at this point), project management overhead (typically 15-20% of development time), and testing overhead (10-15% of development time for a moderately complex project).",[20,103,104],{},"The output is a range, not a single number. \"This project will cost between $85,000 and $115,000 depending on final scope decisions\" is an honest estimate. \"$95,000\" with no context is a guess in a nice suit.",[33,106],{},[15,108,110],{"id":109},"the-scope-items-that-always-get-forgotten","The Scope Items That Always Get Forgotten",[20,112,113],{},"Here's where projects routinely underrun: the work that isn't \"the product\" but is still absolutely required.",[20,115,116,119],{},[57,117,118],{},"Environments."," You need development, staging, and production. Setting them up, maintaining CI/CD pipelines, and managing deployments is real work.",[20,121,122,125],{},[57,123,124],{},"Third-party integrations."," Payment processors, email services, CRMs, analytics platforms — every integration has documentation gaps, unexpected edge cases, and testing requirements. Budget accordingly.",[20,127,128,131],{},[57,129,130],{},"Admin interfaces."," Somebody has to manage users, configure settings, and review data. That's usually an internal admin panel that clients forget to scope until they see the MVP and ask \"but how do we manage it?\"",[20,133,134,137],{},[57,135,136],{},"Error handling and logging."," A well-built system has structured logging, error monitoring (Sentry or equivalent), and alerting. This isn't glamorous but it's non-optional for production software.",[20,139,140,143],{},[57,141,142],{},"Data migration."," If there's an existing system being replaced, migrating the data is often a project within the project. Treat it that way.",[20,145,146,149],{},[57,147,148],{},"Documentation and handoff."," Someone needs to write the deployment guide, the environment variable documentation, and the architecture overview. If that's you, scope it. If it's not, make sure the client knows it isn't.",[33,151],{},[15,153,155],{"id":154},"having-the-pricing-conversation","Having the Pricing Conversation",[20,157,158],{},"Clients who haven't bought custom software before often anchor on software prices they've heard second-hand. They've seen estimates for projects that aren't theirs, for developers who aren't you, from years ago. You'll have to educate.",[20,160,161],{},"Be direct about how your pricing works, what's included and excluded, and why the number is what it is. Walk through the WBS. Show the estimate methodology. Don't apologize for the number — defend it with specificity.",[20,163,164],{},"The clients who try to grind you down to an unrealistic price will be the hardest clients to work with. The clients who say \"this is more than I expected, can you walk me through how you got there?\" are showing you they're operating in good faith.",[33,166],{},[20,168,169,170,177],{},"Pricing software projects is part craft, part conversation. If you're working on a project and want a second opinion on scope or budget, book a call at ",[171,172,176],"a",{"href":173,"rel":174},"https://calendly.com/jamesrossjr",[175],"nofollow","calendly.com/jamesrossjr"," — I'm happy to give you an honest read on whether the numbers make sense.",[33,179],{},[15,181,183],{"id":182},"keep-reading","Keep Reading",[185,186,187,194,200,206],"ul",{},[188,189,190],"li",{},[171,191,193],{"href":192},"/blog/scope-creep-prevention","Scope Creep Prevention: How to Keep Custom Software Projects on Track",[188,195,196],{},[171,197,199],{"href":198},"/blog/freelance-developer-vs-agency","Freelance Developer vs Software Agency: How to Choose the Right Partner",[188,201,202],{},[171,203,205],{"href":204},"/blog/hiring-software-development-company","Hiring a Software Development Company: What to Look For, What to Avoid",[188,207,208],{},[171,209,211],{"href":210},"/blog/saas-pricing-models","SaaS Pricing Models: Per Seat, Usage-Based, and Everything In Between",{"title":213,"searchDepth":214,"depth":214,"links":215},"",3,[216,218,219,220,221,222,223],{"id":17,"depth":217,"text":18},2,{"id":37,"depth":217,"text":38},{"id":52,"depth":217,"text":53},{"id":86,"depth":217,"text":87},{"id":109,"depth":217,"text":110},{"id":154,"depth":217,"text":155},{"id":182,"depth":217,"text":183},"Business","2026-03-03","Pricing custom software is one of the hardest conversations in the industry. Here's the framework I use to scope, estimate, and price projects accurately and fairly.","md",false,null,[231,232],"pricing software projects","custom software cost",{},true,"/blog/pricing-software-projects",7,{"title":7,"description":226},"blog/pricing-software-projects",[240,241,242],"Business Strategy","Software Pricing","Project Management","iXvykSpWLeRCCZemrvzm5u8sjXyZgLDe5I-6wMmpEK0",{"id":245,"title":246,"author":247,"body":248,"category":2154,"date":225,"description":2155,"extension":227,"featured":228,"image":229,"keywords":2156,"meta":2159,"navigation":234,"path":2160,"readTime":236,"seo":2161,"stem":2162,"tags":2163,"__hash__":2167},"blog/blog/prisma-orm-guide.md","Prisma ORM: A Complete Guide for TypeScript Developers",{"name":9,"bio":10},{"type":12,"value":249,"toc":2144},[250,253,256,260,263,551,554,564,574,588,597,601,604,817,821,839,1038,1044,1164,1167,1192,1195,1199,1202,1492,1498,1501,1561,1565,1568,1614,1624,1627,1638,1641,1656,1660,1663,1764,1769,1773,1776,1954,2096,2099,2102,2104,2110,2112,2114,2140],[20,251,252],{},"Prisma is the ORM I recommend to most TypeScript developers working on relational databases. The developer experience is excellent, the generated types match your schema exactly, and the migration workflow is reliable. After shipping dozens of production applications with Prisma, I have a clear picture of where it shines and where to be careful.",[20,254,255],{},"This article walks through the patterns I use in production — not the documentation examples, but the real patterns that hold up under load and through team scaling.",[15,257,259],{"id":258},"schema-design-fundamentals","Schema Design Fundamentals",[20,261,262],{},"The Prisma schema is where your data model lives. Design it deliberately:",[264,265,269],"pre",{"className":266,"code":267,"language":268,"meta":213,"style":213},"language-prisma shiki shiki-themes github-dark","// schema.prisma\ngenerator client {\n provider = \"prisma-client-js\"\n}\n\nDatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\nModel User {\n id String @id @default(cuid())\n email String @unique\n name String?\n avatarUrl String?\n role Role @default(VIEWER)\n posts Post[]\n comments Comment[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n deletedAt DateTime?\n\n @@index([email])\n @@index([deletedAt])\n}\n\nModel Post {\n id String @id @default(cuid())\n title String\n slug String @unique\n content String\n published Boolean @default(false)\n author User @relation(fields: [authorId], references: [id])\n authorId String\n tags Tag[]\n comments Comment[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([authorId])\n @@index([slug])\n @@index([published, createdAt(sort: Desc)])\n}\n\nEnum Role {\n ADMIN\n EDITOR\n VIEWER\n}\n","prisma",[95,270,271,279,284,289,295,301,307,312,318,323,328,334,340,346,352,358,364,370,376,382,388,394,399,405,411,416,421,427,432,438,444,450,456,462,468,474,479,484,489,494,500,506,512,517,522,528,534,540,546],{"__ignoreMap":213},[272,273,276],"span",{"class":274,"line":275},"line",1,[272,277,278],{},"// schema.prisma\n",[272,280,281],{"class":274,"line":217},[272,282,283],{},"generator client {\n",[272,285,286],{"class":274,"line":214},[272,287,288],{}," provider = \"prisma-client-js\"\n",[272,290,292],{"class":274,"line":291},4,[272,293,294],{},"}\n",[272,296,298],{"class":274,"line":297},5,[272,299,300],{"emptyLinePlaceholder":234},"\n",[272,302,304],{"class":274,"line":303},6,[272,305,306],{},"Datasource db {\n",[272,308,309],{"class":274,"line":236},[272,310,311],{}," provider = \"postgresql\"\n",[272,313,315],{"class":274,"line":314},8,[272,316,317],{}," url = env(\"DATABASE_URL\")\n",[272,319,321],{"class":274,"line":320},9,[272,322,294],{},[272,324,326],{"class":274,"line":325},10,[272,327,300],{"emptyLinePlaceholder":234},[272,329,331],{"class":274,"line":330},11,[272,332,333],{},"Model User {\n",[272,335,337],{"class":274,"line":336},12,[272,338,339],{}," id String @id @default(cuid())\n",[272,341,343],{"class":274,"line":342},13,[272,344,345],{}," email String @unique\n",[272,347,349],{"class":274,"line":348},14,[272,350,351],{}," name String?\n",[272,353,355],{"class":274,"line":354},15,[272,356,357],{}," avatarUrl String?\n",[272,359,361],{"class":274,"line":360},16,[272,362,363],{}," role Role @default(VIEWER)\n",[272,365,367],{"class":274,"line":366},17,[272,368,369],{}," posts Post[]\n",[272,371,373],{"class":274,"line":372},18,[272,374,375],{}," comments Comment[]\n",[272,377,379],{"class":274,"line":378},19,[272,380,381],{}," createdAt DateTime @default(now())\n",[272,383,385],{"class":274,"line":384},20,[272,386,387],{}," updatedAt DateTime @updatedAt\n",[272,389,391],{"class":274,"line":390},21,[272,392,393],{}," deletedAt DateTime?\n",[272,395,397],{"class":274,"line":396},22,[272,398,300],{"emptyLinePlaceholder":234},[272,400,402],{"class":274,"line":401},23,[272,403,404],{}," @@index([email])\n",[272,406,408],{"class":274,"line":407},24,[272,409,410],{}," @@index([deletedAt])\n",[272,412,414],{"class":274,"line":413},25,[272,415,294],{},[272,417,419],{"class":274,"line":418},26,[272,420,300],{"emptyLinePlaceholder":234},[272,422,424],{"class":274,"line":423},27,[272,425,426],{},"Model Post {\n",[272,428,430],{"class":274,"line":429},28,[272,431,339],{},[272,433,435],{"class":274,"line":434},29,[272,436,437],{}," title String\n",[272,439,441],{"class":274,"line":440},30,[272,442,443],{}," slug String @unique\n",[272,445,447],{"class":274,"line":446},31,[272,448,449],{}," content String\n",[272,451,453],{"class":274,"line":452},32,[272,454,455],{}," published Boolean @default(false)\n",[272,457,459],{"class":274,"line":458},33,[272,460,461],{}," author User @relation(fields: [authorId], references: [id])\n",[272,463,465],{"class":274,"line":464},34,[272,466,467],{}," authorId String\n",[272,469,471],{"class":274,"line":470},35,[272,472,473],{}," tags Tag[]\n",[272,475,477],{"class":274,"line":476},36,[272,478,375],{},[272,480,482],{"class":274,"line":481},37,[272,483,381],{},[272,485,487],{"class":274,"line":486},38,[272,488,387],{},[272,490,492],{"class":274,"line":491},39,[272,493,300],{"emptyLinePlaceholder":234},[272,495,497],{"class":274,"line":496},40,[272,498,499],{}," @@index([authorId])\n",[272,501,503],{"class":274,"line":502},41,[272,504,505],{}," @@index([slug])\n",[272,507,509],{"class":274,"line":508},42,[272,510,511],{}," @@index([published, createdAt(sort: Desc)])\n",[272,513,515],{"class":274,"line":514},43,[272,516,294],{},[272,518,520],{"class":274,"line":519},44,[272,521,300],{"emptyLinePlaceholder":234},[272,523,525],{"class":274,"line":524},45,[272,526,527],{},"Enum Role {\n",[272,529,531],{"class":274,"line":530},46,[272,532,533],{}," ADMIN\n",[272,535,537],{"class":274,"line":536},47,[272,538,539],{}," EDITOR\n",[272,541,543],{"class":274,"line":542},48,[272,544,545],{}," VIEWER\n",[272,547,549],{"class":274,"line":548},49,[272,550,294],{},[20,552,553],{},"Key schema decisions:",[20,555,556,563],{},[57,557,558,559,562],{},"Use ",[95,560,561],{},"cuid()"," for IDs."," UUIDs work but CUID2 generates IDs that are sortable by creation time (like a timestamp prefix), which improves database locality for sequential writes.",[20,565,566,573],{},[57,567,568,569,572],{},"Add ",[95,570,571],{},"@@index"," for every foreign key."," Prisma does not create them automatically. Unindexed foreign keys cause sequential scans on JOINs.",[20,575,576,583,584,587],{},[57,577,578,579,582],{},"Add soft delete with ",[95,580,581],{},"deletedAt","."," For most business applications, deleted records need audit trails or recovery capabilities. Filter with ",[95,585,586],{},"where: { deletedAt: null }"," in queries.",[20,589,590,596],{},[57,591,558,592,595],{},[95,593,594],{},"@updatedAt"," on mutable models."," Prisma automatically sets this on every update — it is a useful timestamp for cache invalidation and audit purposes.",[15,598,600],{"id":599},"the-client-singleton","The Client Singleton",[20,602,603],{},"Always use a singleton for the Prisma client. Without it, each hot reload in development creates a new client and exhausts database connections:",[264,605,609],{"className":606,"code":607,"language":608,"meta":213,"style":213},"language-typescript shiki shiki-themes github-dark","// lib/prisma.ts\nimport { PrismaClient } from '@prisma/client'\n\nConst globalForPrisma = globalThis as unknown as {\n prisma: PrismaClient | undefined\n}\n\nExport const prisma =\n globalForPrisma.prisma ??\n new PrismaClient({\n log:\n process.env.NODE_ENV === 'development'\n ? ['query', 'warn', 'error']\n : ['error'],\n })\n\nIf (process.env.NODE_ENV !== 'production') {\n globalForPrisma.prisma = prisma\n}\n","typescript",[95,610,611,617,634,638,662,681,685,689,702,710,720,725,739,764,776,781,785,804,813],{"__ignoreMap":213},[272,612,613],{"class":274,"line":275},[272,614,616],{"class":615},"sAwPA","// lib/prisma.ts\n",[272,618,619,623,627,630],{"class":274,"line":217},[272,620,622],{"class":621},"snl16","import",[272,624,626],{"class":625},"s95oV"," { PrismaClient } ",[272,628,629],{"class":621},"from",[272,631,633],{"class":632},"sU2Wk"," '@prisma/client'\n",[272,635,636],{"class":274,"line":214},[272,637,300],{"emptyLinePlaceholder":234},[272,639,640,643,646,649,652,656,659],{"class":274,"line":291},[272,641,642],{"class":625},"Const globalForPrisma ",[272,644,645],{"class":621},"=",[272,647,648],{"class":625}," globalThis ",[272,650,651],{"class":621},"as",[272,653,655],{"class":654},"sDLfK"," unknown",[272,657,658],{"class":621}," as",[272,660,661],{"class":625}," {\n",[272,663,664,668,671,675,678],{"class":274,"line":297},[272,665,667],{"class":666},"s9osk"," prisma",[272,669,670],{"class":621},":",[272,672,674],{"class":673},"svObZ"," PrismaClient",[272,676,677],{"class":621}," |",[272,679,680],{"class":654}," undefined\n",[272,682,683],{"class":274,"line":303},[272,684,294],{"class":625},[272,686,687],{"class":274,"line":236},[272,688,300],{"emptyLinePlaceholder":234},[272,690,691,694,697,699],{"class":274,"line":314},[272,692,693],{"class":625},"Export ",[272,695,696],{"class":621},"const",[272,698,667],{"class":654},[272,700,701],{"class":621}," =\n",[272,703,704,707],{"class":274,"line":320},[272,705,706],{"class":625}," globalForPrisma.prisma ",[272,708,709],{"class":621},"??\n",[272,711,712,715,717],{"class":274,"line":325},[272,713,714],{"class":621}," new",[272,716,674],{"class":673},[272,718,719],{"class":625},"({\n",[272,721,722],{"class":274,"line":330},[272,723,724],{"class":625}," log:\n",[272,726,727,730,733,736],{"class":274,"line":336},[272,728,729],{"class":625}," process.env.",[272,731,732],{"class":654},"NODE_ENV",[272,734,735],{"class":621}," ===",[272,737,738],{"class":632}," 'development'\n",[272,740,741,744,747,750,753,756,758,761],{"class":274,"line":342},[272,742,743],{"class":621}," ?",[272,745,746],{"class":625}," [",[272,748,749],{"class":632},"'query'",[272,751,752],{"class":625},", ",[272,754,755],{"class":632},"'warn'",[272,757,752],{"class":625},[272,759,760],{"class":632},"'error'",[272,762,763],{"class":625},"]\n",[272,765,766,769,771,773],{"class":274,"line":348},[272,767,768],{"class":621}," :",[272,770,746],{"class":625},[272,772,760],{"class":632},[272,774,775],{"class":625},"],\n",[272,777,778],{"class":274,"line":354},[272,779,780],{"class":625}," })\n",[272,782,783],{"class":274,"line":360},[272,784,300],{"emptyLinePlaceholder":234},[272,786,787,790,793,795,798,801],{"class":274,"line":366},[272,788,789],{"class":673},"If",[272,791,792],{"class":625}," (process.env.",[272,794,732],{"class":654},[272,796,797],{"class":621}," !==",[272,799,800],{"class":632}," 'production'",[272,802,803],{"class":625},") {\n",[272,805,806,808,810],{"class":274,"line":372},[272,807,706],{"class":625},[272,809,645],{"class":621},[272,811,812],{"class":625}," prisma\n",[272,814,815],{"class":274,"line":378},[272,816,294],{"class":625},[15,818,820],{"id":819},"query-patterns","Query Patterns",[20,822,823,826,827,830,831,834,835,838],{},[57,824,825],{},"Select only what you need."," The default ",[95,828,829],{},"findMany"," with ",[95,832,833],{},"include"," fetches all columns. For list views where you only show a few fields, use ",[95,836,837],{},"select"," to reduce data transfer:",[264,840,842],{"className":606,"code":841,"language":608,"meta":213,"style":213},"// Instead of this (fetches all columns including large content field)\nconst posts = await prisma.post.findMany({\n include: { author: true },\n})\n\n// Use this for a posts list page\nconst posts = await prisma.post.findMany({\n select: {\n id: true,\n title: true,\n slug: true,\n createdAt: true,\n author: {\n select: {\n id: true,\n name: true,\n avatarUrl: true,\n },\n },\n },\n where: { published: true, deletedAt: null },\n orderBy: { createdAt: 'desc' },\n take: 20,\n})\n",[95,843,844,849,869,880,885,889,894,910,915,925,934,943,952,957,961,969,978,987,991,995,999,1014,1024,1034],{"__ignoreMap":213},[272,845,846],{"class":274,"line":275},[272,847,848],{"class":615},"// Instead of this (fetches all columns including large content field)\n",[272,850,851,853,856,859,862,865,867],{"class":274,"line":217},[272,852,696],{"class":621},[272,854,855],{"class":654}," posts",[272,857,858],{"class":621}," =",[272,860,861],{"class":621}," await",[272,863,864],{"class":625}," prisma.post.",[272,866,829],{"class":673},[272,868,719],{"class":625},[272,870,871,874,877],{"class":274,"line":214},[272,872,873],{"class":625}," include: { author: ",[272,875,876],{"class":654},"true",[272,878,879],{"class":625}," },\n",[272,881,882],{"class":274,"line":291},[272,883,884],{"class":625},"})\n",[272,886,887],{"class":274,"line":297},[272,888,300],{"emptyLinePlaceholder":234},[272,890,891],{"class":274,"line":303},[272,892,893],{"class":615},"// Use this for a posts list page\n",[272,895,896,898,900,902,904,906,908],{"class":274,"line":236},[272,897,696],{"class":621},[272,899,855],{"class":654},[272,901,858],{"class":621},[272,903,861],{"class":621},[272,905,864],{"class":625},[272,907,829],{"class":673},[272,909,719],{"class":625},[272,911,912],{"class":274,"line":314},[272,913,914],{"class":625}," select: {\n",[272,916,917,920,922],{"class":274,"line":320},[272,918,919],{"class":625}," id: ",[272,921,876],{"class":654},[272,923,924],{"class":625},",\n",[272,926,927,930,932],{"class":274,"line":325},[272,928,929],{"class":625}," title: ",[272,931,876],{"class":654},[272,933,924],{"class":625},[272,935,936,939,941],{"class":274,"line":330},[272,937,938],{"class":625}," slug: ",[272,940,876],{"class":654},[272,942,924],{"class":625},[272,944,945,948,950],{"class":274,"line":336},[272,946,947],{"class":625}," createdAt: ",[272,949,876],{"class":654},[272,951,924],{"class":625},[272,953,954],{"class":274,"line":342},[272,955,956],{"class":625}," author: {\n",[272,958,959],{"class":274,"line":348},[272,960,914],{"class":625},[272,962,963,965,967],{"class":274,"line":354},[272,964,919],{"class":625},[272,966,876],{"class":654},[272,968,924],{"class":625},[272,970,971,974,976],{"class":274,"line":360},[272,972,973],{"class":625}," name: ",[272,975,876],{"class":654},[272,977,924],{"class":625},[272,979,980,983,985],{"class":274,"line":366},[272,981,982],{"class":625}," avatarUrl: ",[272,984,876],{"class":654},[272,986,924],{"class":625},[272,988,989],{"class":274,"line":372},[272,990,879],{"class":625},[272,992,993],{"class":274,"line":378},[272,994,879],{"class":625},[272,996,997],{"class":274,"line":384},[272,998,879],{"class":625},[272,1000,1001,1004,1006,1009,1012],{"class":274,"line":390},[272,1002,1003],{"class":625}," where: { published: ",[272,1005,876],{"class":654},[272,1007,1008],{"class":625},", deletedAt: ",[272,1010,1011],{"class":654},"null",[272,1013,879],{"class":625},[272,1015,1016,1019,1022],{"class":274,"line":396},[272,1017,1018],{"class":625}," orderBy: { createdAt: ",[272,1020,1021],{"class":632},"'desc'",[272,1023,879],{"class":625},[272,1025,1026,1029,1032],{"class":274,"line":401},[272,1027,1028],{"class":625}," take: ",[272,1030,1031],{"class":654},"20",[272,1033,924],{"class":625},[272,1035,1036],{"class":274,"line":407},[272,1037,884],{"class":625},[20,1039,1040,1043],{},[57,1041,1042],{},"Avoid N+1 queries."," This is the most common Prisma performance mistake. Fetching posts and then fetching the author for each post in a loop:",[264,1045,1047],{"className":606,"code":1046,"language":608,"meta":213,"style":213},"// BAD: N+1 — 1 query for posts + 1 query per post for author\nconst posts = await prisma.post.findMany()\nfor (const post of posts) {\n const author = await prisma.user.findUnique({ where: { id: post.authorId } })\n // ...\n}\n\n// GOOD: Single query with include\nconst posts = await prisma.post.findMany({\n include: { author: { select: { id: true, name: true } } },\n})\n",[95,1048,1049,1054,1071,1090,1111,1116,1120,1124,1129,1145,1160],{"__ignoreMap":213},[272,1050,1051],{"class":274,"line":275},[272,1052,1053],{"class":615},"// BAD: N+1 — 1 query for posts + 1 query per post for author\n",[272,1055,1056,1058,1060,1062,1064,1066,1068],{"class":274,"line":217},[272,1057,696],{"class":621},[272,1059,855],{"class":654},[272,1061,858],{"class":621},[272,1063,861],{"class":621},[272,1065,864],{"class":625},[272,1067,829],{"class":673},[272,1069,1070],{"class":625},"()\n",[272,1072,1073,1076,1079,1081,1084,1087],{"class":274,"line":214},[272,1074,1075],{"class":621},"for",[272,1077,1078],{"class":625}," (",[272,1080,696],{"class":621},[272,1082,1083],{"class":654}," post",[272,1085,1086],{"class":621}," of",[272,1088,1089],{"class":625}," posts) {\n",[272,1091,1092,1095,1098,1100,1102,1105,1108],{"class":274,"line":291},[272,1093,1094],{"class":621}," const",[272,1096,1097],{"class":654}," author",[272,1099,858],{"class":621},[272,1101,861],{"class":621},[272,1103,1104],{"class":625}," prisma.user.",[272,1106,1107],{"class":673},"findUnique",[272,1109,1110],{"class":625},"({ where: { id: post.authorId } })\n",[272,1112,1113],{"class":274,"line":297},[272,1114,1115],{"class":615}," // ...\n",[272,1117,1118],{"class":274,"line":303},[272,1119,294],{"class":625},[272,1121,1122],{"class":274,"line":236},[272,1123,300],{"emptyLinePlaceholder":234},[272,1125,1126],{"class":274,"line":314},[272,1127,1128],{"class":615},"// GOOD: Single query with include\n",[272,1130,1131,1133,1135,1137,1139,1141,1143],{"class":274,"line":320},[272,1132,696],{"class":621},[272,1134,855],{"class":654},[272,1136,858],{"class":621},[272,1138,861],{"class":621},[272,1140,864],{"class":625},[272,1142,829],{"class":673},[272,1144,719],{"class":625},[272,1146,1147,1150,1152,1155,1157],{"class":274,"line":325},[272,1148,1149],{"class":625}," include: { author: { select: { id: ",[272,1151,876],{"class":654},[272,1153,1154],{"class":625},", name: ",[272,1156,876],{"class":654},[272,1158,1159],{"class":625}," } } },\n",[272,1161,1162],{"class":274,"line":330},[272,1163,884],{"class":625},[20,1165,1166],{},"Enable query logging in development to catch N+1 patterns:",[264,1168,1170],{"className":606,"code":1169,"language":608,"meta":213,"style":213},"const prisma = new PrismaClient({ log: ['query'] })\n",[95,1171,1172],{"__ignoreMap":213},[272,1173,1174,1176,1178,1180,1182,1184,1187,1189],{"class":274,"line":275},[272,1175,696],{"class":621},[272,1177,667],{"class":654},[272,1179,858],{"class":621},[272,1181,714],{"class":621},[272,1183,674],{"class":673},[272,1185,1186],{"class":625},"({ log: [",[272,1188,749],{"class":632},[272,1190,1191],{"class":625},"] })\n",[20,1193,1194],{},"Any route that logs 10+ queries for a single request is an N+1 candidate.",[15,1196,1198],{"id":1197},"transactions","Transactions",[20,1200,1201],{},"Use transactions for operations that must succeed or fail together:",[264,1203,1205],{"className":606,"code":1204,"language":608,"meta":213,"style":213},"// Transfer credits between accounts\nasync function transferCredits(\n fromId: string,\n toId: string,\n amount: number\n): Promise\u003Cvoid> {\n await prisma.$transaction(async (tx) => {\n const from = await tx.account.findUniqueOrThrow({\n where: { id: fromId },\n })\n\n if (from.credits \u003C amount) {\n throw new Error('Insufficient credits')\n }\n\n await tx.account.update({\n where: { id: fromId },\n data: { credits: { decrement: amount } },\n })\n\n await tx.account.update({\n where: { id: toId },\n data: { credits: { increment: amount } },\n })\n\n await tx.transaction.create({\n data: {\n fromId,\n toId,\n amount,\n type: 'TRANSFER',\n },\n })\n })\n}\n",[95,1206,1207,1212,1226,1238,1249,1259,1278,1306,1325,1330,1334,1338,1351,1369,1374,1378,1389,1393,1398,1402,1406,1416,1421,1426,1430,1434,1446,1451,1456,1461,1466,1476,1480,1484,1488],{"__ignoreMap":213},[272,1208,1209],{"class":274,"line":275},[272,1210,1211],{"class":615},"// Transfer credits between accounts\n",[272,1213,1214,1217,1220,1223],{"class":274,"line":217},[272,1215,1216],{"class":621},"async",[272,1218,1219],{"class":621}," function",[272,1221,1222],{"class":673}," transferCredits",[272,1224,1225],{"class":625},"(\n",[272,1227,1228,1231,1233,1236],{"class":274,"line":214},[272,1229,1230],{"class":666}," fromId",[272,1232,670],{"class":621},[272,1234,1235],{"class":654}," string",[272,1237,924],{"class":625},[272,1239,1240,1243,1245,1247],{"class":274,"line":291},[272,1241,1242],{"class":666}," toId",[272,1244,670],{"class":621},[272,1246,1235],{"class":654},[272,1248,924],{"class":625},[272,1250,1251,1254,1256],{"class":274,"line":297},[272,1252,1253],{"class":666}," amount",[272,1255,670],{"class":621},[272,1257,1258],{"class":654}," number\n",[272,1260,1261,1264,1266,1269,1272,1275],{"class":274,"line":303},[272,1262,1263],{"class":625},")",[272,1265,670],{"class":621},[272,1267,1268],{"class":673}," Promise",[272,1270,1271],{"class":625},"\u003C",[272,1273,1274],{"class":654},"void",[272,1276,1277],{"class":625},"> {\n",[272,1279,1280,1282,1285,1288,1291,1293,1295,1298,1301,1304],{"class":274,"line":236},[272,1281,861],{"class":621},[272,1283,1284],{"class":625}," prisma.",[272,1286,1287],{"class":673},"$transaction",[272,1289,1290],{"class":625},"(",[272,1292,1216],{"class":621},[272,1294,1078],{"class":625},[272,1296,1297],{"class":666},"tx",[272,1299,1300],{"class":625},") ",[272,1302,1303],{"class":621},"=>",[272,1305,661],{"class":625},[272,1307,1308,1310,1313,1315,1317,1320,1323],{"class":274,"line":314},[272,1309,1094],{"class":621},[272,1311,1312],{"class":654}," from",[272,1314,858],{"class":621},[272,1316,861],{"class":621},[272,1318,1319],{"class":625}," tx.account.",[272,1321,1322],{"class":673},"findUniqueOrThrow",[272,1324,719],{"class":625},[272,1326,1327],{"class":274,"line":320},[272,1328,1329],{"class":625}," where: { id: fromId },\n",[272,1331,1332],{"class":274,"line":325},[272,1333,780],{"class":625},[272,1335,1336],{"class":274,"line":330},[272,1337,300],{"emptyLinePlaceholder":234},[272,1339,1340,1343,1346,1348],{"class":274,"line":336},[272,1341,1342],{"class":621}," if",[272,1344,1345],{"class":625}," (from.credits ",[272,1347,1271],{"class":621},[272,1349,1350],{"class":625}," amount) {\n",[272,1352,1353,1356,1358,1361,1363,1366],{"class":274,"line":342},[272,1354,1355],{"class":621}," throw",[272,1357,714],{"class":621},[272,1359,1360],{"class":673}," Error",[272,1362,1290],{"class":625},[272,1364,1365],{"class":632},"'Insufficient credits'",[272,1367,1368],{"class":625},")\n",[272,1370,1371],{"class":274,"line":348},[272,1372,1373],{"class":625}," }\n",[272,1375,1376],{"class":274,"line":354},[272,1377,300],{"emptyLinePlaceholder":234},[272,1379,1380,1382,1384,1387],{"class":274,"line":360},[272,1381,861],{"class":621},[272,1383,1319],{"class":625},[272,1385,1386],{"class":673},"update",[272,1388,719],{"class":625},[272,1390,1391],{"class":274,"line":366},[272,1392,1329],{"class":625},[272,1394,1395],{"class":274,"line":372},[272,1396,1397],{"class":625}," data: { credits: { decrement: amount } },\n",[272,1399,1400],{"class":274,"line":378},[272,1401,780],{"class":625},[272,1403,1404],{"class":274,"line":384},[272,1405,300],{"emptyLinePlaceholder":234},[272,1407,1408,1410,1412,1414],{"class":274,"line":390},[272,1409,861],{"class":621},[272,1411,1319],{"class":625},[272,1413,1386],{"class":673},[272,1415,719],{"class":625},[272,1417,1418],{"class":274,"line":396},[272,1419,1420],{"class":625}," where: { id: toId },\n",[272,1422,1423],{"class":274,"line":401},[272,1424,1425],{"class":625}," data: { credits: { increment: amount } },\n",[272,1427,1428],{"class":274,"line":407},[272,1429,780],{"class":625},[272,1431,1432],{"class":274,"line":413},[272,1433,300],{"emptyLinePlaceholder":234},[272,1435,1436,1438,1441,1444],{"class":274,"line":418},[272,1437,861],{"class":621},[272,1439,1440],{"class":625}," tx.transaction.",[272,1442,1443],{"class":673},"create",[272,1445,719],{"class":625},[272,1447,1448],{"class":274,"line":423},[272,1449,1450],{"class":625}," data: {\n",[272,1452,1453],{"class":274,"line":429},[272,1454,1455],{"class":625}," fromId,\n",[272,1457,1458],{"class":274,"line":434},[272,1459,1460],{"class":625}," toId,\n",[272,1462,1463],{"class":274,"line":440},[272,1464,1465],{"class":625}," amount,\n",[272,1467,1468,1471,1474],{"class":274,"line":446},[272,1469,1470],{"class":625}," type: ",[272,1472,1473],{"class":632},"'TRANSFER'",[272,1475,924],{"class":625},[272,1477,1478],{"class":274,"line":452},[272,1479,879],{"class":625},[272,1481,1482],{"class":274,"line":458},[272,1483,780],{"class":625},[272,1485,1486],{"class":274,"line":464},[272,1487,780],{"class":625},[272,1489,1490],{"class":274,"line":470},[272,1491,294],{"class":625},[20,1493,1494,1495,1497],{},"If any operation inside ",[95,1496,1287],{}," throws, all operations roll back automatically.",[20,1499,1500],{},"For long-running transactions, configure the timeout:",[264,1502,1504],{"className":606,"code":1503,"language":608,"meta":213,"style":213},"await prisma.$transaction(\n async (tx) => {\n // Long operation\n },\n { timeout: 10000, maxWait: 5000 }\n)\n",[95,1505,1506,1517,1532,1537,1541,1557],{"__ignoreMap":213},[272,1507,1508,1511,1513,1515],{"class":274,"line":275},[272,1509,1510],{"class":621},"await",[272,1512,1284],{"class":625},[272,1514,1287],{"class":673},[272,1516,1225],{"class":625},[272,1518,1519,1522,1524,1526,1528,1530],{"class":274,"line":217},[272,1520,1521],{"class":621}," async",[272,1523,1078],{"class":625},[272,1525,1297],{"class":666},[272,1527,1300],{"class":625},[272,1529,1303],{"class":621},[272,1531,661],{"class":625},[272,1533,1534],{"class":274,"line":214},[272,1535,1536],{"class":615}," // Long operation\n",[272,1538,1539],{"class":274,"line":291},[272,1540,879],{"class":625},[272,1542,1543,1546,1549,1552,1555],{"class":274,"line":297},[272,1544,1545],{"class":625}," { timeout: ",[272,1547,1548],{"class":654},"10000",[272,1550,1551],{"class":625},", maxWait: ",[272,1553,1554],{"class":654},"5000",[272,1556,1373],{"class":625},[272,1558,1559],{"class":274,"line":303},[272,1560,1368],{"class":625},[15,1562,1564],{"id":1563},"migrations-in-production","Migrations in Production",[20,1566,1567],{},"The migration workflow for production:",[264,1569,1573],{"className":1570,"code":1571,"language":1572,"meta":213,"style":213},"language-bash shiki shiki-themes github-dark","# Development: create and apply a migration\nprisma migrate dev --name add_user_role\n\n# Production: apply pending migrations\nprisma migrate deploy\n","bash",[95,1574,1575,1580,1596,1600,1605],{"__ignoreMap":213},[272,1576,1577],{"class":274,"line":275},[272,1578,1579],{"class":615},"# Development: create and apply a migration\n",[272,1581,1582,1584,1587,1590,1593],{"class":274,"line":217},[272,1583,268],{"class":673},[272,1585,1586],{"class":632}," migrate",[272,1588,1589],{"class":632}," dev",[272,1591,1592],{"class":654}," --name",[272,1594,1595],{"class":632}," add_user_role\n",[272,1597,1598],{"class":274,"line":214},[272,1599,300],{"emptyLinePlaceholder":234},[272,1601,1602],{"class":274,"line":291},[272,1603,1604],{"class":615},"# Production: apply pending migrations\n",[272,1606,1607,1609,1611],{"class":274,"line":297},[272,1608,268],{"class":673},[272,1610,1586],{"class":632},[272,1612,1613],{"class":632}," deploy\n",[20,1615,1616,1617,1620,1621,582],{},"Never run ",[95,1618,1619],{},"prisma migrate dev"," in production — it may generate and apply unanticipated migrations. Always use ",[95,1622,1623],{},"prisma migrate deploy",[20,1625,1626],{},"For zero-downtime migrations with large tables, be careful about blocking operations:",[185,1628,1629,1632,1635],{},[188,1630,1631],{},"Adding a NOT NULL column with no default locks the table while it sets the default",[188,1633,1634],{},"Adding an index on a large table can cause I/O pressure that slows other queries",[188,1636,1637],{},"Renaming a column requires updating application code simultaneously",[20,1639,1640],{},"For these cases, use multi-step migrations:",[1642,1643,1644,1647,1650,1653],"ol",{},[188,1645,1646],{},"Add the new column as nullable (no lock)",[188,1648,1649],{},"Backfill existing rows (can do this in batches without locks)",[188,1651,1652],{},"Add the NOT NULL constraint",[188,1654,1655],{},"Deploy application code that uses the new column",[15,1657,1659],{"id":1658},"raw-queries-for-complex-operations","Raw Queries for Complex Operations",[20,1661,1662],{},"When Prisma's query API is not expressive enough, drop to raw SQL:",[264,1664,1666],{"className":606,"code":1665,"language":608,"meta":213,"style":213},"// Complex query that Prisma cannot express efficiently\nconst results = await prisma.$queryRaw\u003CUserStats[]>`\n SELECT\n u.id,\n u.name,\n COUNT(p.id) AS post_count,\n MAX(p.created_at) AS last_post_at,\n COALESCE(SUM(p.view_count), 0) AS total_views\n FROM users u\n LEFT JOIN posts p ON p.author_id = u.id AND p.published = true\n WHERE u.deleted_at IS NULL\n GROUP BY u.id, u.name\n ORDER BY total_views DESC\n LIMIT 10\n`\n",[95,1667,1668,1673,1700,1705,1710,1715,1720,1725,1730,1735,1740,1745,1750,1755,1760],{"__ignoreMap":213},[272,1669,1670],{"class":274,"line":275},[272,1671,1672],{"class":615},"// Complex query that Prisma cannot express efficiently\n",[272,1674,1675,1677,1680,1682,1684,1686,1689,1691,1694,1697],{"class":274,"line":217},[272,1676,696],{"class":621},[272,1678,1679],{"class":654}," results",[272,1681,858],{"class":621},[272,1683,861],{"class":621},[272,1685,1284],{"class":625},[272,1687,1688],{"class":673},"$queryRaw",[272,1690,1271],{"class":625},[272,1692,1693],{"class":673},"UserStats",[272,1695,1696],{"class":625},"[]>",[272,1698,1699],{"class":632},"`\n",[272,1701,1702],{"class":274,"line":214},[272,1703,1704],{"class":632}," SELECT\n",[272,1706,1707],{"class":274,"line":291},[272,1708,1709],{"class":632}," u.id,\n",[272,1711,1712],{"class":274,"line":297},[272,1713,1714],{"class":632}," u.name,\n",[272,1716,1717],{"class":274,"line":303},[272,1718,1719],{"class":632}," COUNT(p.id) AS post_count,\n",[272,1721,1722],{"class":274,"line":236},[272,1723,1724],{"class":632}," MAX(p.created_at) AS last_post_at,\n",[272,1726,1727],{"class":274,"line":314},[272,1728,1729],{"class":632}," COALESCE(SUM(p.view_count), 0) AS total_views\n",[272,1731,1732],{"class":274,"line":320},[272,1733,1734],{"class":632}," FROM users u\n",[272,1736,1737],{"class":274,"line":325},[272,1738,1739],{"class":632}," LEFT JOIN posts p ON p.author_id = u.id AND p.published = true\n",[272,1741,1742],{"class":274,"line":330},[272,1743,1744],{"class":632}," WHERE u.deleted_at IS NULL\n",[272,1746,1747],{"class":274,"line":336},[272,1748,1749],{"class":632}," GROUP BY u.id, u.name\n",[272,1751,1752],{"class":274,"line":342},[272,1753,1754],{"class":632}," ORDER BY total_views DESC\n",[272,1756,1757],{"class":274,"line":348},[272,1758,1759],{"class":632}," LIMIT 10\n",[272,1761,1762],{"class":274,"line":354},[272,1763,1699],{"class":632},[20,1765,1766,1768],{},[95,1767,1688],{}," uses parameterized queries by default (via template literals), preventing SQL injection. Never concatenate user input directly into raw query strings.",[15,1770,1772],{"id":1771},"middleware-for-audit-logging","Middleware for Audit Logging",[20,1774,1775],{},"Prisma middleware intercepts queries and can add cross-cutting behavior:",[264,1777,1779],{"className":606,"code":1778,"language":608,"meta":213,"style":213},"prisma.$use(async (params, next) => {\n const before = Date.now()\n const result = await next(params)\n const after = Date.now()\n\n if (after - before > 100) {\n console.warn(\n `Slow Prisma query: ${params.model}.${params.action} (${after - before}ms)`\n )\n }\n\n return result\n})\n",[95,1780,1781,1809,1826,1843,1858,1862,1883,1893,1929,1934,1938,1942,1950],{"__ignoreMap":213},[272,1782,1783,1786,1789,1791,1793,1795,1798,1800,1803,1805,1807],{"class":274,"line":275},[272,1784,1785],{"class":625},"prisma.",[272,1787,1788],{"class":673},"$use",[272,1790,1290],{"class":625},[272,1792,1216],{"class":621},[272,1794,1078],{"class":625},[272,1796,1797],{"class":666},"params",[272,1799,752],{"class":625},[272,1801,1802],{"class":666},"next",[272,1804,1300],{"class":625},[272,1806,1303],{"class":621},[272,1808,661],{"class":625},[272,1810,1811,1813,1816,1818,1821,1824],{"class":274,"line":217},[272,1812,1094],{"class":621},[272,1814,1815],{"class":654}," before",[272,1817,858],{"class":621},[272,1819,1820],{"class":625}," Date.",[272,1822,1823],{"class":673},"now",[272,1825,1070],{"class":625},[272,1827,1828,1830,1833,1835,1837,1840],{"class":274,"line":214},[272,1829,1094],{"class":621},[272,1831,1832],{"class":654}," result",[272,1834,858],{"class":621},[272,1836,861],{"class":621},[272,1838,1839],{"class":673}," next",[272,1841,1842],{"class":625},"(params)\n",[272,1844,1845,1847,1850,1852,1854,1856],{"class":274,"line":291},[272,1846,1094],{"class":621},[272,1848,1849],{"class":654}," after",[272,1851,858],{"class":621},[272,1853,1820],{"class":625},[272,1855,1823],{"class":673},[272,1857,1070],{"class":625},[272,1859,1860],{"class":274,"line":297},[272,1861,300],{"emptyLinePlaceholder":234},[272,1863,1864,1866,1869,1872,1875,1878,1881],{"class":274,"line":303},[272,1865,1342],{"class":621},[272,1867,1868],{"class":625}," (after ",[272,1870,1871],{"class":621},"-",[272,1873,1874],{"class":625}," before ",[272,1876,1877],{"class":621},">",[272,1879,1880],{"class":654}," 100",[272,1882,803],{"class":625},[272,1884,1885,1888,1891],{"class":274,"line":236},[272,1886,1887],{"class":625}," console.",[272,1889,1890],{"class":673},"warn",[272,1892,1225],{"class":625},[272,1894,1895,1898,1900,1902,1905,1908,1910,1912,1915,1918,1921,1924,1926],{"class":274,"line":314},[272,1896,1897],{"class":632}," `Slow Prisma query: ${",[272,1899,1797],{"class":625},[272,1901,582],{"class":632},[272,1903,1904],{"class":625},"model",[272,1906,1907],{"class":632},"}.${",[272,1909,1797],{"class":625},[272,1911,582],{"class":632},[272,1913,1914],{"class":625},"action",[272,1916,1917],{"class":632},"} (${",[272,1919,1920],{"class":625},"after",[272,1922,1923],{"class":621}," -",[272,1925,1815],{"class":625},[272,1927,1928],{"class":632},"}ms)`\n",[272,1930,1931],{"class":274,"line":320},[272,1932,1933],{"class":625}," )\n",[272,1935,1936],{"class":274,"line":325},[272,1937,1373],{"class":625},[272,1939,1940],{"class":274,"line":330},[272,1941,300],{"emptyLinePlaceholder":234},[272,1943,1944,1947],{"class":274,"line":336},[272,1945,1946],{"class":621}," return",[272,1948,1949],{"class":625}," result\n",[272,1951,1952],{"class":274,"line":342},[272,1953,884],{"class":625},[264,1955,1957],{"className":606,"code":1956,"language":608,"meta":213,"style":213},"// Soft delete middleware\nprisma.$use(async (params, next) => {\n if (params.action === 'delete') {\n params.action = 'update'\n params.args.data = { deletedAt: new Date() }\n }\n\n if (params.action === 'deleteMany') {\n params.action = 'updateMany'\n params.args.data = { deletedAt: new Date() }\n }\n\n return next(params)\n})\n",[95,1958,1959,1964,1988,2003,2013,2032,2036,2040,2053,2062,2076,2080,2084,2092],{"__ignoreMap":213},[272,1960,1961],{"class":274,"line":275},[272,1962,1963],{"class":615},"// Soft delete middleware\n",[272,1965,1966,1968,1970,1972,1974,1976,1978,1980,1982,1984,1986],{"class":274,"line":217},[272,1967,1785],{"class":625},[272,1969,1788],{"class":673},[272,1971,1290],{"class":625},[272,1973,1216],{"class":621},[272,1975,1078],{"class":625},[272,1977,1797],{"class":666},[272,1979,752],{"class":625},[272,1981,1802],{"class":666},[272,1983,1300],{"class":625},[272,1985,1303],{"class":621},[272,1987,661],{"class":625},[272,1989,1990,1992,1995,1998,2001],{"class":274,"line":214},[272,1991,1342],{"class":621},[272,1993,1994],{"class":625}," (params.action ",[272,1996,1997],{"class":621},"===",[272,1999,2000],{"class":632}," 'delete'",[272,2002,803],{"class":625},[272,2004,2005,2008,2010],{"class":274,"line":291},[272,2006,2007],{"class":625}," params.action ",[272,2009,645],{"class":621},[272,2011,2012],{"class":632}," 'update'\n",[272,2014,2015,2018,2020,2023,2026,2029],{"class":274,"line":297},[272,2016,2017],{"class":625}," params.args.data ",[272,2019,645],{"class":621},[272,2021,2022],{"class":625}," { deletedAt: ",[272,2024,2025],{"class":621},"new",[272,2027,2028],{"class":673}," Date",[272,2030,2031],{"class":625},"() }\n",[272,2033,2034],{"class":274,"line":303},[272,2035,1373],{"class":625},[272,2037,2038],{"class":274,"line":236},[272,2039,300],{"emptyLinePlaceholder":234},[272,2041,2042,2044,2046,2048,2051],{"class":274,"line":314},[272,2043,1342],{"class":621},[272,2045,1994],{"class":625},[272,2047,1997],{"class":621},[272,2049,2050],{"class":632}," 'deleteMany'",[272,2052,803],{"class":625},[272,2054,2055,2057,2059],{"class":274,"line":320},[272,2056,2007],{"class":625},[272,2058,645],{"class":621},[272,2060,2061],{"class":632}," 'updateMany'\n",[272,2063,2064,2066,2068,2070,2072,2074],{"class":274,"line":325},[272,2065,2017],{"class":625},[272,2067,645],{"class":621},[272,2069,2022],{"class":625},[272,2071,2025],{"class":621},[272,2073,2028],{"class":673},[272,2075,2031],{"class":625},[272,2077,2078],{"class":274,"line":330},[272,2079,1373],{"class":625},[272,2081,2082],{"class":274,"line":336},[272,2083,300],{"emptyLinePlaceholder":234},[272,2085,2086,2088,2090],{"class":274,"line":342},[272,2087,1946],{"class":621},[272,2089,1839],{"class":673},[272,2091,1842],{"class":625},[272,2093,2094],{"class":274,"line":348},[272,2095,884],{"class":625},[20,2097,2098],{},"Prisma's middleware system is powerful for cross-cutting concerns but adds overhead to every query. Use it judiciously.",[20,2100,2101],{},"Prisma is a mature, excellent ORM for TypeScript backend applications. The schema-first approach, the generated types, and the migration workflow are all well-designed. Use the patterns above consistently and you will avoid most of the pitfalls.",[33,2103],{},[20,2105,2106,2107,582],{},"Building with Prisma and running into schema design questions, migration challenges, or performance issues? Book a call and we can work through it: ",[171,2108,176],{"href":173,"rel":2109},[175],[33,2111],{},[15,2113,183],{"id":182},[185,2115,2116,2122,2128,2134],{},[188,2117,2118],{},[171,2119,2121],{"href":2120},"/blog/drizzle-orm-vs-prisma","Drizzle ORM vs Prisma: Which Should You Use in 2026?",[188,2123,2124],{},[171,2125,2127],{"href":2126},"/blog/building-rest-apis-typescript","Building REST APIs With TypeScript: Patterns From Production",[188,2129,2130],{},[171,2131,2133],{"href":2132},"/blog/typescript-backend-development","TypeScript for Backend Development: Patterns I Use on Every Project",[188,2135,2136],{},[171,2137,2139],{"href":2138},"/blog/nuxt-typescript-guide","TypeScript in Nuxt: Getting the Type Safety You Actually Want",[2141,2142,2143],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":213,"searchDepth":214,"depth":214,"links":2145},[2146,2147,2148,2149,2150,2151,2152,2153],{"id":258,"depth":217,"text":259},{"id":599,"depth":217,"text":600},{"id":819,"depth":217,"text":820},{"id":1197,"depth":217,"text":1198},{"id":1563,"depth":217,"text":1564},{"id":1658,"depth":217,"text":1659},{"id":1771,"depth":217,"text":1772},{"id":182,"depth":217,"text":183},"Engineering","A complete Prisma ORM guide for TypeScript developers — schema design, relations, migrations, query optimization, transactions, and production patterns that actually work.",[2157,2158],"Prisma ORM","TypeScript ORM",{},"/blog/prisma-orm-guide",{"title":246,"description":2155},"blog/prisma-orm-guide",[2164,2165,2166],"Prisma","Database","TypeScript","wdkBItwzh-88C5R5rmoll4bfTz9vjgUteADvF2ztbHw",{"id":2169,"title":2170,"author":2171,"body":2172,"category":2154,"date":225,"description":2563,"extension":227,"featured":228,"image":229,"keywords":2564,"meta":2567,"navigation":234,"path":2568,"readTime":236,"seo":2569,"stem":2570,"tags":2571,"__hash__":2575},"blog/blog/product-led-growth-technical.md","Product-Led Growth: The Technical Architecture Behind Virality",{"name":9,"bio":10},{"type":12,"value":2173,"toc":2553},[2174,2178,2181,2184,2186,2190,2193,2199,2205,2211,2217,2219,2223,2226,2376,2379,2381,2385,2388,2394,2400,2406,2408,2412,2415,2425,2431,2437,2443,2445,2449,2452,2460,2463,2468,2488,2491,2493,2497,2500,2503,2506,2509,2512,2514,2520,2522,2524,2550],[15,2175,2177],{"id":2176},"plg-is-an-architecture-decision-not-just-a-marketing-strategy","PLG Is an Architecture Decision, Not Just a Marketing Strategy",[20,2179,2180],{},"Product-led growth (PLG) gets discussed primarily as a go-to-market strategy: let users discover value in the product before involving sales, and let the product drive its own adoption. This is accurate. But what's less often discussed is that PLG has real technical architecture implications.",[20,2182,2183],{},"A product that's designed to spread through an organization — through invitations, collaborative features, and viral loops — has different requirements than a product that's sold top-down by a sales team. The data model, the sharing mechanisms, the notification infrastructure, and the instrumentation all need to be built for PLG intentionally. Adding these features after the fact is significantly harder than building them in from the start.",[33,2185],{},[15,2187,2189],{"id":2188},"the-product-led-loop-architecture","The Product-Led Loop Architecture",[20,2191,2192],{},"PLG products grow through a predictable pattern: a user gets value → they invite others → those others get value → they spread it further. Each step in this loop has technical requirements:",[20,2194,2195,2198],{},[57,2196,2197],{},"User gets value quickly."," This is the activation problem. PLG depends on users reaching their \"aha moment\" without a sales rep explaining the product to them. The activation flow needs to be frictionless, with no unnecessary gates between signup and core value.",[20,2200,2201,2204],{},[57,2202,2203],{},"Sharing and collaboration create pull."," The most powerful PLG vector is when using the product creates a reason for others to join. Notion's shared docs, Figma's design sharing, Loom's video links — the product's core artifact is something you share with people who don't have accounts. Those recipients need to click through and get value before being asked to sign up.",[20,2206,2207,2210],{},[57,2208,2209],{},"The invite mechanism is seamless."," When a user wants to share the product with a colleague, the flow should take under 60 seconds and produce immediate value for the recipient. An invite flow that requires manual approval, excessive setup, or a confusing onboarding sequence kills the viral loop.",[20,2212,2213,2216],{},[57,2214,2215],{},"Attribution tells you where users came from."," For PLG to be measurable and optimizable, you need to know which users came through which viral paths. This requires invitation attribution tracking — connecting the invited user to the inviter, and the inviter to the product experience that motivated them to invite.",[33,2218],{},[15,2220,2222],{"id":2221},"the-technical-data-model-for-plg","The Technical Data Model for PLG",[20,2224,2225],{},"Here's the core data model additions that PLG requires:",[264,2227,2231],{"className":2228,"code":2229,"language":2230,"meta":213,"style":213},"language-sql shiki shiki-themes github-dark","-- track how users joined\nusers (\n id, email, ...,\n acquisition_source, -- 'organic', 'invite', 'referral', 'paid'\n invited_by_user_id, -- null if organic\n invite_token_id -- the specific invite they used\n)\n\n-- invitation system\ninvitations (\n id, inviter_id, invitee_email, organization_id,\n token, status, -- pending, accepted, expired\n accepted_at, expires_at, created_at\n)\n\n-- product sharing (for sharing product artifacts)\nshared_links (\n id, resource_type, resource_id, created_by,\n token, permission_level, -- view, comment, edit\n access_count, first_accessed_at, last_accessed_at,\n expires_at, created_at\n)\n\n-- viral loop tracking\nreferral_events (\n id, actor_id, action, -- 'invited', 'shared', 'accepted'\n resource_type, resource_id,\n resulted_in_signup_id, -- if the action led to a signup\n created_at\n)\n","sql",[95,2232,2233,2238,2243,2248,2253,2258,2263,2267,2271,2276,2281,2286,2291,2296,2300,2304,2309,2314,2319,2324,2329,2334,2338,2342,2347,2352,2357,2362,2367,2372],{"__ignoreMap":213},[272,2234,2235],{"class":274,"line":275},[272,2236,2237],{},"-- track how users joined\n",[272,2239,2240],{"class":274,"line":217},[272,2241,2242],{},"users (\n",[272,2244,2245],{"class":274,"line":214},[272,2246,2247],{}," id, email, ...,\n",[272,2249,2250],{"class":274,"line":291},[272,2251,2252],{}," acquisition_source, -- 'organic', 'invite', 'referral', 'paid'\n",[272,2254,2255],{"class":274,"line":297},[272,2256,2257],{}," invited_by_user_id, -- null if organic\n",[272,2259,2260],{"class":274,"line":303},[272,2261,2262],{}," invite_token_id -- the specific invite they used\n",[272,2264,2265],{"class":274,"line":236},[272,2266,1368],{},[272,2268,2269],{"class":274,"line":314},[272,2270,300],{"emptyLinePlaceholder":234},[272,2272,2273],{"class":274,"line":320},[272,2274,2275],{},"-- invitation system\n",[272,2277,2278],{"class":274,"line":325},[272,2279,2280],{},"invitations (\n",[272,2282,2283],{"class":274,"line":330},[272,2284,2285],{}," id, inviter_id, invitee_email, organization_id,\n",[272,2287,2288],{"class":274,"line":336},[272,2289,2290],{}," token, status, -- pending, accepted, expired\n",[272,2292,2293],{"class":274,"line":342},[272,2294,2295],{}," accepted_at, expires_at, created_at\n",[272,2297,2298],{"class":274,"line":348},[272,2299,1368],{},[272,2301,2302],{"class":274,"line":354},[272,2303,300],{"emptyLinePlaceholder":234},[272,2305,2306],{"class":274,"line":360},[272,2307,2308],{},"-- product sharing (for sharing product artifacts)\n",[272,2310,2311],{"class":274,"line":366},[272,2312,2313],{},"shared_links (\n",[272,2315,2316],{"class":274,"line":372},[272,2317,2318],{}," id, resource_type, resource_id, created_by,\n",[272,2320,2321],{"class":274,"line":378},[272,2322,2323],{}," token, permission_level, -- view, comment, edit\n",[272,2325,2326],{"class":274,"line":384},[272,2327,2328],{}," access_count, first_accessed_at, last_accessed_at,\n",[272,2330,2331],{"class":274,"line":390},[272,2332,2333],{}," expires_at, created_at\n",[272,2335,2336],{"class":274,"line":396},[272,2337,1368],{},[272,2339,2340],{"class":274,"line":401},[272,2341,300],{"emptyLinePlaceholder":234},[272,2343,2344],{"class":274,"line":407},[272,2345,2346],{},"-- viral loop tracking\n",[272,2348,2349],{"class":274,"line":413},[272,2350,2351],{},"referral_events (\n",[272,2353,2354],{"class":274,"line":418},[272,2355,2356],{}," id, actor_id, action, -- 'invited', 'shared', 'accepted'\n",[272,2358,2359],{"class":274,"line":423},[272,2360,2361],{}," resource_type, resource_id,\n",[272,2363,2364],{"class":274,"line":429},[272,2365,2366],{}," resulted_in_signup_id, -- if the action led to a signup\n",[272,2368,2369],{"class":274,"line":434},[272,2370,2371],{}," created_at\n",[272,2373,2374],{"class":274,"line":440},[272,2375,1368],{},[20,2377,2378],{},"This model lets you answer: what percentage of new signups came through viral channels? Which users invite the most? Which product features generate the most shares?",[33,2380],{},[15,2382,2384],{"id":2383},"self-serve-access-patterns","Self-Serve Access Patterns",[20,2386,2387],{},"PLG requires self-serve access at every level:",[20,2389,2390,2393],{},[57,2391,2392],{},"Guest access."," Recipients of shared links should be able to experience the product's core value without creating an account. Figma lets you view and comment on designs as a guest. Notion lets you read shared pages. This removes the biggest friction from the first viral step — the person who receives the share shouldn't need to sign up before they see the value.",[20,2395,2396,2399],{},[57,2397,2398],{},"Team invitations from inside the product."," The invitation to join should be triggerable by any user (or admin-only, based on your policy) directly from the product UI. Don't make users go through a settings menu buried three levels deep.",[20,2401,2402,2405],{},[57,2403,2404],{},"Automatic organization creation."," When a user signs up without an existing organization to join, create an organization for them automatically. If they later invite others, those people join their organization. If they're invited to an existing organization, they join that one. This needs to handle the edge case where someone signs up independently and later gets invited to an existing org — do you merge? Do you let them maintain both?",[33,2407],{},[15,2409,2411],{"id":2410},"the-freemium-models-technical-requirements","The Freemium Model's Technical Requirements",[20,2413,2414],{},"PLG almost always involves a freemium tier — a free access level that provides genuine value while creating incentives to upgrade. The technical implementation of freemium requires:",[20,2416,2417,2420,2421,2424],{},[57,2418,2419],{},"Feature flagging by plan."," A centralized system that controls which features are available to which users based on their subscription tier. This should be the single source of truth — not scattered ",[95,2422,2423],{},"if (user.plan === 'free')"," checks throughout the codebase.",[20,2426,2427,2430],{},[57,2428,2429],{},"Usage limits by plan."," Free tiers often have limits: 3 projects, 5 team members, 1GB of storage. Track usage against limits in real time and provide clear feedback when approaching limits. The upgrade prompt should appear in context (\"you've used 4 of 5 projects — upgrade to create unlimited projects\") rather than on a pricing page.",[20,2432,2433,2436],{},[57,2434,2435],{},"Graceful limit enforcement."," When a user hits a free tier limit, the experience should guide them toward upgrading, not leave them confused or blocked. The upgrade CTA should be immediate, clear, and linked to the specific feature they were trying to use.",[20,2438,2439,2442],{},[57,2440,2441],{},"Usage reset logic."," Some limits are per-period (X emails per month) rather than absolute (X projects total). Build the reset logic correctly from the start — what period are you tracking, when does it reset, and how do you handle mid-period upgrades?",[33,2444],{},[15,2446,2448],{"id":2447},"instrumentation-for-plg","Instrumentation for PLG",[20,2450,2451],{},"PLG depends on measurement. The viral coefficient — the number of new users that each existing user generates — is a mathematical function of your sharing rate and conversion rate. You need to measure both.",[20,2453,2454,2457],{},[57,2455,2456],{},"Viral coefficient calculation:",[95,2458,2459],{},"K = (invitations sent per user) × (conversion rate of invitations)",[20,2461,2462],{},"If each user sends 2 invitations on average, and 30% of those invitations convert to signups, K = 0.6. A K value above 1 means the product is growing virally (every user generates more than one new user). A K value below 1 means growth requires ongoing top-of-funnel investment.",[20,2464,2465],{},[57,2466,2467],{},"What to instrument:",[185,2469,2470,2473,2476,2479,2482,2485],{},[188,2471,2472],{},"Invite sent events (who invited, to what email, from which product surface)",[188,2474,2475],{},"Invite opened events (did the recipient open the email?)",[188,2477,2478],{},"Invitation conversion rate (signed up → activated)",[188,2480,2481],{},"Share link created events",[188,2483,2484],{},"Share link visited events (including by non-users)",[188,2486,2487],{},"Share-to-signup conversion rate",[20,2489,2490],{},"Run these metrics weekly. The invite → accept → activate funnel is the PLG loop. Every percentage point improvement in each step compounds.",[33,2492],{},[15,2494,2496],{"id":2495},"the-network-effects-layer","The Network Effects Layer",[20,2498,2499],{},"The highest-leverage PLG products have network effects — the product becomes more valuable as more people use it. This is architectural.",[20,2501,2502],{},"For a collaboration tool: the more of your team that uses it, the more valuable it is for everyone. The product design needs to create visible benefits to having teammates in the product (activity feeds, collaborative cursors, comment notifications).",[20,2504,2505],{},"For a marketplace or community: the value is in the other users. The product needs to surface the community as the value proposition, not just the software itself.",[20,2507,2508],{},"For a data product: the more organizations that contribute data, the better the benchmarks. Aggregate data needs to be a product feature, not just a backend detail.",[20,2510,2511],{},"Network effects don't happen by accident. They need to be designed in.",[33,2513],{},[20,2515,2516,2517,582],{},"PLG is one of the most powerful growth strategies available to SaaS founders, but it requires building the right technical foundations from the start. If you're designing a SaaS product with PLG in mind and want to think through the architecture, book a call at ",[171,2518,176],{"href":173,"rel":2519},[175],[33,2521],{},[15,2523,183],{"id":182},[185,2525,2526,2532,2538,2544],{},[188,2527,2528],{},[171,2529,2531],{"href":2530},"/blog/saas-development-guide","SaaS Development Guide: From Idea to Paying Customers",[188,2533,2534],{},[171,2535,2537],{"href":2536},"/blog/multi-tenant-architecture","Multi-Tenant Architecture: Patterns for Building Software That Serves Many Clients",[188,2539,2540],{},[171,2541,2543],{"href":2542},"/blog/saas-onboarding-best-practices","SaaS Onboarding: The Technical and UX Decisions That Determine Activation",[188,2545,2546],{},[171,2547,2549],{"href":2548},"/blog/b2b-saas-development","B2B SaaS Development: What's Different About Building for Businesses",[2141,2551,2552],{},"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);}",{"title":213,"searchDepth":214,"depth":214,"links":2554},[2555,2556,2557,2558,2559,2560,2561,2562],{"id":2176,"depth":217,"text":2177},{"id":2188,"depth":217,"text":2189},{"id":2221,"depth":217,"text":2222},{"id":2383,"depth":217,"text":2384},{"id":2410,"depth":217,"text":2411},{"id":2447,"depth":217,"text":2448},{"id":2495,"depth":217,"text":2496},{"id":182,"depth":217,"text":183},"PLG is a growth strategy with real technical implications. Here's the architecture, instrumentation, and product patterns that make product-led growth actually work.",[2565,2566],"product-led growth","PLG technical implementation",{},"/blog/product-led-growth-technical",{"title":2170,"description":2563},"blog/product-led-growth-technical",[2572,2573,2574],"Product-Led Growth","SaaS","Software Architecture","83pz-ZjX3LIoR12ayq2bRIM4inLaqkIOQ2u0kBQvse0",{"id":2577,"title":2578,"author":2579,"body":2580,"category":3054,"date":225,"description":3055,"extension":227,"featured":228,"image":229,"keywords":3056,"meta":3059,"navigation":234,"path":3060,"readTime":236,"seo":3061,"stem":3062,"tags":3063,"__hash__":3067},"blog/blog/production-monitoring-guide.md","Production Monitoring: The Metrics That Actually Tell You Something Is Wrong",{"name":9,"bio":10},{"type":12,"value":2581,"toc":3044},[2582,2586,2589,2592,2596,2599,2605,2611,2617,2623,2626,2630,2633,2636,2639,2642,2646,2649,2934,2937,2940,2943,2947,2950,2953,2956,2960,2963,2966,2977,2980,2984,2987,2990,2993,2997,3000,3003,3005,3011,3013,3015,3041],[2583,2584,2578],"h1",{"id":2585},"production-monitoring-the-metrics-that-actually-tell-you-something-is-wrong",[20,2587,2588],{},"Most teams I work with are over-monitored and under-observant. They have dashboards full of metrics — CPU usage, memory consumption, disk I/O, network bytes — and then something catastrophic happens and none of those metrics told them anything useful beforehand. The database connection pool saturated silently. The background job queue backed up for six hours. Users were getting 503 errors while every server health check showed green.",[20,2590,2591],{},"The problem is not the absence of monitoring. It is monitoring the wrong things. Let me tell you what I actually watch in production and why.",[15,2593,2595],{"id":2594},"the-four-golden-signals","The Four Golden Signals",[20,2597,2598],{},"The Site Reliability Engineering book from Google defined four signals worth measuring for every production service. Fifteen years later, this framework is still the best starting point I know.",[20,2600,2601,2604],{},[57,2602,2603],{},"Latency"," — how long requests take to process. The crucial detail is measuring this correctly: track the latency of successful requests separately from failed requests. A spike in error rate with fast error responses can make your average latency look healthy while users are experiencing failures. Percentiles matter more than averages — p99 latency tells you what your slowest 1% of users experience. That is often the number that predicts support tickets.",[20,2606,2607,2610],{},[57,2608,2609],{},"Traffic"," — how much demand your service is handling. Requests per second for HTTP services, messages per second for queues, queries per second for databases. Traffic is the demand signal. When combined with latency and errors, traffic tells you whether a degradation is correlated with load or happening regardless of load.",[20,2612,2613,2616],{},[57,2614,2615],{},"Errors"," — the rate of requests that fail. Track explicit failures (5xx HTTP responses) separately from implicit failures (200 responses with error payloads, timeouts that resolve with empty data). Many error conditions masquerade as successes at the protocol level.",[20,2618,2619,2622],{},[57,2620,2621],{},"Saturation"," — how full your service is. CPU and memory are the obvious ones, but more important for most application servers are: database connection pool use, open file descriptor count, thread pool queue depth. A service operating at 70% of its connection pool limit needs attention before the pool exhausts.",[20,2624,2625],{},"Alert on golden signals, not infrastructure metrics. CPU usage is a poor predictor of user-visible problems. Error rate is an excellent predictor.",[15,2627,2629],{"id":2628},"setting-alert-thresholds-that-mean-something","Setting Alert Thresholds That Mean Something",[20,2631,2632],{},"Bad alerting is worse than no alerting. Alert fatigue — where your on-call rotation ignores alerts because they fire constantly and are almost always false positives — is a genuine organizational problem. It means the alert that matters gets ignored along with the noise.",[20,2634,2635],{},"Alert on symptoms, not causes. \"Error rate above 1% for 5 minutes\" is a symptom alert. \"CPU above 80%\" is a cause alert. Cause alerts require you to make a judgment about whether this CPU spike will cause user-visible problems. Symptom alerts tell you user-visible problems are already happening.",[20,2637,2638],{},"Set your alert thresholds based on observed baselines, not guesses. Instrument your system for a week, establish what normal looks like, and set alerts at meaningful deviations. A p99 latency spike to 3 seconds means something different for a batch processing service than for a payment API.",[20,2640,2641],{},"Use multi-condition alerts where appropriate. A single machine showing high CPU is probably fine. All machines showing high CPU simultaneously is a serious event. Your alerting system should be able to express this distinction.",[15,2643,2645],{"id":2644},"what-to-actually-instrument","What to Actually Instrument",[20,2647,2648],{},"Every API endpoint needs latency and status code tracking. In Node.js with Express, a middleware handles this:",[264,2650,2652],{"className":606,"code":2651,"language":608,"meta":213,"style":213},"import { Request, Response, NextFunction } from \"express\";\nimport { metrics } from \"./metrics\"; // your metrics client\n\nExport function httpMetricsMiddleware(\n req: Request,\n res: Response,\n next: NextFunction\n): void {\n const start = process.hrtime.bigint();\n\n res.on(\"finish\", () => {\n const duration = Number(process.hrtime.bigint() - start) / 1e6; // ms\n metrics.histogram(\"http.request.duration\", duration, {\n method: req.method,\n route: req.route?.path ?? \"unknown\",\n status: String(res.statusCode),\n });\n metrics.increment(\"http.requests.total\", {\n method: req.method,\n route: req.route?.path ?? \"unknown\",\n status: String(res.statusCode),\n });\n });\n\n next();\n}\n",[95,2653,2654,2669,2687,2691,2703,2715,2727,2736,2747,2765,2769,2789,2825,2841,2846,2859,2870,2875,2890,2894,2904,2912,2916,2920,2924,2930],{"__ignoreMap":213},[272,2655,2656,2658,2661,2663,2666],{"class":274,"line":275},[272,2657,622],{"class":621},[272,2659,2660],{"class":625}," { Request, Response, NextFunction } ",[272,2662,629],{"class":621},[272,2664,2665],{"class":632}," \"express\"",[272,2667,2668],{"class":625},";\n",[272,2670,2671,2673,2676,2678,2681,2684],{"class":274,"line":217},[272,2672,622],{"class":621},[272,2674,2675],{"class":625}," { metrics } ",[272,2677,629],{"class":621},[272,2679,2680],{"class":632}," \"./metrics\"",[272,2682,2683],{"class":625},"; ",[272,2685,2686],{"class":615},"// your metrics client\n",[272,2688,2689],{"class":274,"line":214},[272,2690,300],{"emptyLinePlaceholder":234},[272,2692,2693,2695,2698,2701],{"class":274,"line":291},[272,2694,693],{"class":625},[272,2696,2697],{"class":621},"function",[272,2699,2700],{"class":673}," httpMetricsMiddleware",[272,2702,1225],{"class":625},[272,2704,2705,2708,2710,2713],{"class":274,"line":297},[272,2706,2707],{"class":666}," req",[272,2709,670],{"class":621},[272,2711,2712],{"class":673}," Request",[272,2714,924],{"class":625},[272,2716,2717,2720,2722,2725],{"class":274,"line":303},[272,2718,2719],{"class":666}," res",[272,2721,670],{"class":621},[272,2723,2724],{"class":673}," Response",[272,2726,924],{"class":625},[272,2728,2729,2731,2733],{"class":274,"line":236},[272,2730,1839],{"class":666},[272,2732,670],{"class":621},[272,2734,2735],{"class":673}," NextFunction\n",[272,2737,2738,2740,2742,2745],{"class":274,"line":314},[272,2739,1263],{"class":625},[272,2741,670],{"class":621},[272,2743,2744],{"class":654}," void",[272,2746,661],{"class":625},[272,2748,2749,2751,2754,2756,2759,2762],{"class":274,"line":320},[272,2750,1094],{"class":621},[272,2752,2753],{"class":654}," start",[272,2755,858],{"class":621},[272,2757,2758],{"class":625}," process.hrtime.",[272,2760,2761],{"class":673},"bigint",[272,2763,2764],{"class":625},"();\n",[272,2766,2767],{"class":274,"line":325},[272,2768,300],{"emptyLinePlaceholder":234},[272,2770,2771,2774,2777,2779,2782,2785,2787],{"class":274,"line":330},[272,2772,2773],{"class":625}," res.",[272,2775,2776],{"class":673},"on",[272,2778,1290],{"class":625},[272,2780,2781],{"class":632},"\"finish\"",[272,2783,2784],{"class":625},", () ",[272,2786,1303],{"class":621},[272,2788,661],{"class":625},[272,2790,2791,2793,2796,2798,2801,2804,2806,2809,2811,2814,2817,2820,2822],{"class":274,"line":336},[272,2792,1094],{"class":621},[272,2794,2795],{"class":654}," duration",[272,2797,858],{"class":621},[272,2799,2800],{"class":673}," Number",[272,2802,2803],{"class":625},"(process.hrtime.",[272,2805,2761],{"class":673},[272,2807,2808],{"class":625},"() ",[272,2810,1871],{"class":621},[272,2812,2813],{"class":625}," start) ",[272,2815,2816],{"class":621},"/",[272,2818,2819],{"class":654}," 1e6",[272,2821,2683],{"class":625},[272,2823,2824],{"class":615},"// ms\n",[272,2826,2827,2830,2833,2835,2838],{"class":274,"line":342},[272,2828,2829],{"class":625}," metrics.",[272,2831,2832],{"class":673},"histogram",[272,2834,1290],{"class":625},[272,2836,2837],{"class":632},"\"http.request.duration\"",[272,2839,2840],{"class":625},", duration, {\n",[272,2842,2843],{"class":274,"line":348},[272,2844,2845],{"class":625}," method: req.method,\n",[272,2847,2848,2851,2854,2857],{"class":274,"line":354},[272,2849,2850],{"class":625}," route: req.route?.path ",[272,2852,2853],{"class":621},"??",[272,2855,2856],{"class":632}," \"unknown\"",[272,2858,924],{"class":625},[272,2860,2861,2864,2867],{"class":274,"line":360},[272,2862,2863],{"class":625}," status: ",[272,2865,2866],{"class":673},"String",[272,2868,2869],{"class":625},"(res.statusCode),\n",[272,2871,2872],{"class":274,"line":366},[272,2873,2874],{"class":625}," });\n",[272,2876,2877,2879,2882,2884,2887],{"class":274,"line":372},[272,2878,2829],{"class":625},[272,2880,2881],{"class":673},"increment",[272,2883,1290],{"class":625},[272,2885,2886],{"class":632},"\"http.requests.total\"",[272,2888,2889],{"class":625},", {\n",[272,2891,2892],{"class":274,"line":378},[272,2893,2845],{"class":625},[272,2895,2896,2898,2900,2902],{"class":274,"line":384},[272,2897,2850],{"class":625},[272,2899,2853],{"class":621},[272,2901,2856],{"class":632},[272,2903,924],{"class":625},[272,2905,2906,2908,2910],{"class":274,"line":390},[272,2907,2863],{"class":625},[272,2909,2866],{"class":673},[272,2911,2869],{"class":625},[272,2913,2914],{"class":274,"line":396},[272,2915,2874],{"class":625},[272,2917,2918],{"class":274,"line":401},[272,2919,2874],{"class":625},[272,2921,2922],{"class":274,"line":407},[272,2923,300],{"emptyLinePlaceholder":234},[272,2925,2926,2928],{"class":274,"line":413},[272,2927,1839],{"class":673},[272,2929,2764],{"class":625},[272,2931,2932],{"class":274,"line":418},[272,2933,294],{"class":625},[20,2935,2936],{},"Tag every metric with route and status code. This lets you identify which specific endpoints are slow or erroring, not just that something is wrong somewhere in your service.",[20,2938,2939],{},"For database queries, track query duration and error rate at the per-query level. Most ORM-level slow query logs capture this, but streaming it to your metrics system lets you correlate database degradation with API latency spikes.",[20,2941,2942],{},"For background jobs, track queue depth, job processing time, and job failure rate. A job queue that is growing is a latent problem. A job queue that is growing while failure rate is climbing is an active incident.",[15,2944,2946],{"id":2945},"synthetic-monitoring-for-external-validation","Synthetic Monitoring for External Validation",[20,2948,2949],{},"Internal metrics tell you what your servers observe. Synthetic monitoring tells you what users experience. These are different things.",[20,2951,2952],{},"A synthetic monitor makes real HTTP requests to your production endpoints from external locations on a schedule — every minute or every five minutes. When the request fails or takes longer than your threshold, you alert. This catches situations your internal monitoring misses: your application is healthy but a DNS failure is preventing users from reaching it, your CDN is serving a cached error page, your TLS certificate expired.",[20,2954,2955],{},"For simple HTTP checks, services like Checkly, Better Uptime, or Freshping cost under $30/month and provide meaningful coverage. Set up checks for your homepage, your most critical API endpoints, and your health check endpoint. Verify response content, not just HTTP status — a 200 response with \"Service Unavailable\" in the body has happened to me.",[15,2957,2959],{"id":2958},"log-based-alerting","Log-Based Alerting",[20,2961,2962],{},"Metrics are great for quantitative signals. Logs are essential for qualitative ones. Some error conditions only become visible when you look at what is being logged.",[20,2964,2965],{},"Structure your logs (covered in depth in the structured logging article) and ship them to a searchable log management tool: Datadog, Grafana Loki, Axiom, or Elasticsearch. Create alerts based on log patterns:",[185,2967,2968,2971,2974],{},[188,2969,2970],{},"More than 10 occurrences of \"payment failed\" in 5 minutes",[188,2972,2973],{},"Any occurrence of \"database connection refused\"",[188,2975,2976],{},"Authentication failure rate above a baseline (potential credential stuffing attack)",[20,2978,2979],{},"Log-based alerts catch the errors your code logs but does not count as HTTP errors. Your application might return a 200 with an empty dataset when the database query fails silently. The log line \"Query returned zero results: expected non-empty\" is the signal.",[15,2981,2983],{"id":2982},"dashboards-that-communicate-at-a-glance","Dashboards That Communicate at a Glance",[20,2985,2986],{},"A good monitoring dashboard answers \"is my service healthy right now\" in under three seconds. If you need to study the dashboard to determine system health, the dashboard is too complex.",[20,2988,2989],{},"My standard production dashboard has four panels at the top: current error rate, p99 latency (past hour), current requests per second, and current active connections or thread pool use. Below that, breakdowns by endpoint. These six numbers tell me whether I have a problem and roughly where it is.",[20,2991,2992],{},"Avoid dashboards that show metrics without context. A CPU graph means nothing without a historical baseline. Show the current value alongside a 24-hour sparkline. Show alert thresholds as horizontal lines on the chart. Make normal obvious so abnormal is immediately recognizable.",[15,2994,2996],{"id":2995},"the-on-call-reality","The On-Call Reality",[20,2998,2999],{},"Monitoring is only useful if someone acts on it. Set up your alerting so that pages go to the person who can actually address them, at times when they can actually address them. Schedule-based on-call rotation, clear escalation paths, and documented runbooks for common alerts are as important as the metrics themselves.",[20,3001,3002],{},"Review your alert history monthly. Alerts that fired but required no action are candidates for tuning. Incidents that happened without an alert firing indicate monitoring gaps. Continuous improvement of your monitoring coverage is ongoing operational work, not a one-time setup task.",[33,3004],{},[20,3006,3007,3008,582],{},"Struggling to make sense of your monitoring setup or alert on what actually matters? Let's build a monitoring strategy that fits your system. Book a call at ",[171,3009,173],{"href":173,"rel":3010},[175],[33,3012],{},[15,3014,183],{"id":182},[185,3016,3017,3023,3029,3035],{},[188,3018,3019],{},[171,3020,3022],{"href":3021},"/blog/performance-monitoring-guide","Application Performance Monitoring: Beyond the Health Check Endpoint",[188,3024,3025],{},[171,3026,3028],{"href":3027},"/blog/logging-production-apps","Structured Logging for Production: The Setup You'll Thank Yourself For",[188,3030,3031],{},[171,3032,3034],{"href":3033},"/blog/zero-to-production-nuxt-vercel","Zero to Production: My Nuxt + Vercel Deployment Pipeline",[188,3036,3037],{},[171,3038,3040],{"href":3039},"/blog/container-security-guide","Container Security: Hardening Docker for Production",[2141,3042,3043],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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);}",{"title":213,"searchDepth":214,"depth":214,"links":3045},[3046,3047,3048,3049,3050,3051,3052,3053],{"id":2594,"depth":217,"text":2595},{"id":2628,"depth":217,"text":2629},{"id":2644,"depth":217,"text":2645},{"id":2945,"depth":217,"text":2946},{"id":2958,"depth":217,"text":2959},{"id":2982,"depth":217,"text":2983},{"id":2995,"depth":217,"text":2996},{"id":182,"depth":217,"text":183},"DevOps","Cut through monitoring noise with metrics that matter — error rates, latency percentiles, saturation, and traffic patterns that surface real production problems.",[3057,3058],"production monitoring","application monitoring",{},"/blog/production-monitoring-guide",{"title":2578,"description":3055},"blog/production-monitoring-guide",[3064,3054,3065,3066],"Monitoring","Observability","Production","gjfdjM-lo7xF25rah-SHNkQSjpYt-7RhMqY4634FsNU",{"id":3069,"title":3070,"author":3071,"body":3072,"category":3313,"date":225,"description":3314,"extension":227,"featured":228,"image":229,"keywords":3315,"meta":3318,"navigation":234,"path":3319,"readTime":314,"seo":3320,"stem":3321,"tags":3322,"__hash__":3327},"blog/blog/prompt-engineering-for-developers.md","Prompt Engineering for Software Developers: A Practical Guide",{"name":9,"bio":10},{"type":12,"value":3073,"toc":3298},[3074,3078,3081,3084,3087,3089,3093,3096,3099,3102,3105,3107,3111,3114,3120,3126,3132,3138,3144,3146,3150,3155,3158,3161,3165,3168,3171,3175,3178,3186,3189,3193,3196,3199,3203,3206,3208,3212,3218,3224,3230,3236,3242,3244,3248,3251,3254,3257,3260,3268,3270,3272],[15,3075,3077],{"id":3076},"prompt-engineering-is-real-engineering","Prompt Engineering Is Real Engineering",[20,3079,3080],{},"There's a recurring debate about whether \"prompt engineering\" is a real discipline or just a pretentious term for talking to a chatbot differently. I don't have time for that debate. What I can tell you is that when I write prompts carefully versus carelessly, the outputs are substantially different in quality, consistency, and usefulness. That makes it worth understanding rigorously.",[20,3082,3083],{},"For software developers building systems that use LLMs, prompt engineering is not optional. Your prompts are part of your system's logic. They determine what the model does. Poorly designed prompts produce inconsistent, unpredictable outputs that make your application unreliable. Well-designed prompts produce consistent, controllable outputs you can build on.",[20,3085,3086],{},"Here's what I've learned from building AI-native applications and writing hundreds of production prompts.",[33,3088],{},[15,3090,3092],{"id":3091},"the-mental-model-that-changes-everything","The Mental Model That Changes Everything",[20,3094,3095],{},"Stop thinking of a prompt as a question you're asking. Start thinking of it as a specification you're writing for a capable contractor.",[20,3097,3098],{},"When you hire a contractor, you don't just say \"build me something.\" You provide context about the project, specific requirements, constraints, the format you need deliverables in, and examples of what good looks like. You tell them what's in scope and out of scope. You specify the audience for the work.",[20,3100,3101],{},"LLM prompts work the same way. The more specific and complete your specification, the more useful the output. Vague prompts get vague outputs. Detailed, structured prompts get detailed, structured outputs.",[20,3103,3104],{},"This mental model also helps you think about what goes in the system prompt versus the user turn. The system prompt is your standing instructions — the project brief, the role, the constraints, the output format. The user turn is the specific task for this invocation.",[33,3106],{},[15,3108,3110],{"id":3109},"system-prompt-design","System Prompt Design",[20,3112,3113],{},"The system prompt is the most important prompt you write. It shapes every interaction in the session and is the place to establish:",[20,3115,3116,3119],{},[57,3117,3118],{},"Role and expertise framing."," Tell the model who it is in this context. Not \"you are a helpful assistant\" — that's too generic. \"You are a senior software architect reviewing pull requests for a TypeScript/Node.js codebase\" gives the model a specific lens through which to process every request.",[20,3121,3122,3125],{},[57,3123,3124],{},"Context about the system."," If the model is operating within a specific application context, provide that context explicitly. What does this application do? Who are the users? What are the constraints? A model reviewing code for a financial services application needs to apply different scrutiny than one reviewing code for an internal productivity tool.",[20,3127,3128,3131],{},[57,3129,3130],{},"Output format requirements."," Be explicit about format. If you need JSON, say so and provide the schema. If you need markdown, say so. If you need a specific structure (summary, then details, then recommendations), specify it. Do not leave format to chance.",[20,3133,3134,3137],{},[57,3135,3136],{},"Tone and verbosity."," Specify how the model should communicate. \"Concise, technical, no pleasantries\" produces different output than \"detailed explanations suitable for a non-technical audience.\" Both are valid; pick the one your use case needs.",[20,3139,3140,3143],{},[57,3141,3142],{},"Constraints and exclusions."," Tell the model what it should NOT do. \"Do not speculate about information not provided. If something is unclear, say so explicitly rather than making assumptions.\" Negative constraints are as important as positive ones.",[33,3145],{},[15,3147,3149],{"id":3148},"practical-techniques-that-consistently-work","Practical Techniques That Consistently Work",[3151,3152,3154],"h3",{"id":3153},"few-shot-examples","Few-Shot Examples",[20,3156,3157],{},"If you need consistent output format, show examples. Few-shot prompting — providing 2-5 examples of input/output pairs — is one of the most reliable techniques for format consistency. The model learns your format from examples faster and more reliably than from description alone.",[20,3159,3160],{},"The pattern: after your system instructions, include a section like \"Here are examples of the expected format:\" followed by 3-5 complete input/output pairs that demonstrate exactly what you want.",[3151,3162,3164],{"id":3163},"chain-of-thought-for-complex-reasoning","Chain-of-Thought for Complex Reasoning",[20,3166,3167],{},"For tasks that require multi-step reasoning, instruct the model to think step-by-step before reaching a conclusion. \"Before providing your answer, reason through the problem step by step\" consistently produces better outputs on complex tasks.",[20,3169,3170],{},"Chain-of-thought works because complex reasoning requires intermediate steps. A model that reasons step-by-step externalizes its reasoning process in a way that's both more reliable and more auditable. You can see where it went wrong if the output is incorrect.",[3151,3172,3174],{"id":3173},"structured-output-with-explicit-schemas","Structured Output with Explicit Schemas",[20,3176,3177],{},"I mentioned this in the context of enterprise integration, but it bears emphasis as a prompt engineering technique. When you need structured output, include the exact schema in your prompt:",[264,3179,3184],{"className":3180,"code":3182,"language":3183},[3181],"language-text","Respond with a JSON object conforming to this schema:\n{\n \"summary\": \"string (2-3 sentences)\",\n \"severity\": \"critical | high | medium | low\",\n \"recommendations\": [\"string array, 1-5 items\"],\n \"requires_human_review\": \"boolean\"\n}\n","text",[95,3185,3182],{"__ignoreMap":213},[20,3187,3188],{},"This is more reliable than \"respond with JSON\" and far more reliable than \"respond in a structured way.\"",[3151,3190,3192],{"id":3191},"explicit-uncertainty-instructions","Explicit Uncertainty Instructions",[20,3194,3195],{},"By default, models tend toward confident-sounding answers even when uncertainty is warranted. For applications where appropriate uncertainty is important, instruct the model explicitly: \"If you are not confident about something, say so explicitly. Use language like 'I'm not certain' or 'you should verify this' rather than presenting uncertain information as fact.\"",[20,3197,3198],{},"This is especially important for applications where hallucinated facts have real consequences.",[3151,3200,3202],{"id":3201},"persona-consistency","Persona Consistency",[20,3204,3205],{},"For applications where the AI represents your brand or a specific character, persona consistency requires explicit instruction. Establish the persona in the system prompt and include specific examples of how it should respond. Include \"do not break character\" instructions and specify what to do when users try to manipulate the persona.",[33,3207],{},[15,3209,3211],{"id":3210},"what-not-to-do","What Not to Do",[20,3213,3214,3217],{},[57,3215,3216],{},"Don't cram everything into one massive prompt."," Long, sprawling prompts are hard to maintain, harder to debug, and often less effective than focused prompts because important instructions get buried. Break complex tasks into chains of focused prompts where possible.",[20,3219,3220,3223],{},[57,3221,3222],{},"Don't rely on implicit understanding."," The model cannot infer constraints you haven't stated. If the output must be in English even when the input is in another language, say so. If the response should be no longer than 100 words, say so. Implicit requirements are invisible to the model.",[20,3225,3226,3229],{},[57,3227,3228],{},"Don't hardcode prompts as strings in your application."," Prompts are configuration, not code. They should be stored in versioned configuration files, not scattered as string literals through your codebase. This makes them maintainable, testable, and auditable.",[20,3231,3232,3235],{},[57,3233,3234],{},"Don't skip testing prompts against edge cases."," Prompts that work well on representative inputs often break on edge cases. Test your prompts against empty inputs, very long inputs, inputs in unexpected formats, adversarial inputs, and the specific edge cases most relevant to your domain.",[20,3237,3238,3241],{},[57,3239,3240],{},"Don't ignore the temperature parameter."," Higher temperature produces more creative, varied outputs. Lower temperature produces more deterministic, consistent outputs. For production applications that need consistency (classification, extraction, structured output), use low temperature (0.0-0.3). For creative generation tasks, higher temperature is appropriate.",[33,3243],{},[15,3245,3247],{"id":3246},"prompt-as-code-the-mindset-shift-that-matters","Prompt as Code: The Mindset Shift That Matters",[20,3249,3250],{},"Here's the mindset shift I want to leave you with: treat prompts like code. That means version control. That means review before deployment. That means tests against expected outputs. That means documentation of what a prompt does and why it's structured the way it is.",[20,3252,3253],{},"Most teams treat prompts as disposable one-offs. They write a prompt, it seems to work, they move on. When it breaks, they have no history, no tests, no systematic way to understand why the behavior changed.",[20,3255,3256],{},"The teams doing this well maintain prompt libraries with full version history, have regression test suites for critical prompts, review prompt changes the same way they review code changes, and track prompt performance metrics over time.",[20,3258,3259],{},"That's a significant investment. It's also what separates AI applications that are maintainable and reliable from ones that are fragile and unpredictable.",[20,3261,3262,3263,3267],{},"If you're building applications with LLMs and want to think through prompt architecture and testing strategy, ",[171,3264,3266],{"href":173,"rel":3265},[175],"book a consultation at Calendly",". Getting this right from the start is much cheaper than refactoring it later.",[33,3269],{},[15,3271,183],{"id":182},[185,3273,3274,3280,3286,3292],{},[188,3275,3276],{},[171,3277,3279],{"href":3278},"/blog/building-ai-native-applications","Building AI-Native Applications: Architecture Patterns That Actually Work",[188,3281,3282],{},[171,3283,3285],{"href":3284},"/blog/ai-software-development-trends-2026","AI Software Development Trends for 2026: A Practitioner's View",[188,3287,3288],{},[171,3289,3291],{"href":3290},"/blog/agentic-ai-software-development","Agentic AI Software Development: What It Is and Why It Changes Everything",[188,3293,3294],{},[171,3295,3297],{"href":3296},"/blog/machine-learning-enterprise-software","Machine Learning in Enterprise Software: Where It Adds Real Value",{"title":213,"searchDepth":214,"depth":214,"links":3299},[3300,3301,3302,3303,3310,3311,3312],{"id":3076,"depth":217,"text":3077},{"id":3091,"depth":217,"text":3092},{"id":3109,"depth":217,"text":3110},{"id":3148,"depth":217,"text":3149,"children":3304},[3305,3306,3307,3308,3309],{"id":3153,"depth":214,"text":3154},{"id":3163,"depth":214,"text":3164},{"id":3173,"depth":214,"text":3174},{"id":3191,"depth":214,"text":3192},{"id":3201,"depth":214,"text":3202},{"id":3210,"depth":217,"text":3211},{"id":3246,"depth":217,"text":3247},{"id":182,"depth":217,"text":183},"AI","Practical prompt engineering techniques for developers building with LLMs — from system prompt design to chain-of-thought patterns, with real examples from production systems.",[3316,3317],"prompt engineering developers","AI software development",{},"/blog/prompt-engineering-for-developers",{"title":3070,"description":3314},"blog/prompt-engineering-for-developers",[3323,3313,3324,3325,3326],"Prompt Engineering","LLM","Developer Guide","Software Development","ErXzB_etB0jbPgLqYeby_6EaGINinnXgbAnwQm4atCI",{"id":3329,"title":3330,"author":3331,"body":3333,"category":3857,"date":225,"description":3858,"extension":227,"featured":228,"image":229,"keywords":3859,"meta":3867,"navigation":234,"path":3868,"readTime":325,"seo":3869,"stem":3870,"tags":3871,"__hash__":3877},"blog/blog/r1b-l21-atlantic-celtic-haplogroup.md","What Is R1b-L21? The Atlantic Celtic Haplogroup Explained",{"name":9,"bio":3332},"Author of The Forge of Tongues — 22,000 Years of Migration, Mutation, and Memory",{"type":12,"value":3334,"toc":3845},[3335,3339,3345,3348,3368,3371,3374,3376,3380,3383,3386,3397,3404,3409,3411,3415,3418,3424,3430,3436,3442,3448,3454,3457,3459,3463,3466,3472,3482,3492,3498,3506,3508,3512,3515,3531,3537,3543,3549,3555,3566,3568,3572,3590,3593,3620,3623,3625,3629,3632,3646,3649,3652,3654,3658,3661,3672,3675,3678,3687,3693,3695,3699,3807,3810,3813,3815,3819],[15,3336,3338],{"id":3337},"the-most-common-y-chromosome-in-the-gaelic-world","The Most Common Y-Chromosome in the Gaelic World",[20,3340,3341,3342,582],{},"If you have Irish, Scottish Highland, Welsh, or Breton ancestry and you're male, there's a high probability you carry a Y-chromosome haplogroup called ",[57,3343,3344],{},"R1b-L21",[20,3346,3347],{},"The frequencies are striking:",[185,3349,3350,3353,3356,3359,3362,3365],{},[188,3351,3352],{},"Ireland: approximately 80% of men",[188,3354,3355],{},"Scottish Highlands: similar to Ireland, highest in the Western Isles",[188,3357,3358],{},"Wales: approximately 80–85% of men",[188,3360,3361],{},"Brittany (northwestern France): approximately 70% of men",[188,3363,3364],{},"England: approximately 60–65% of men (lower due to later Germanic migrations)",[188,3366,3367],{},"Iberia (Spain, Portugal): 50–70%",[20,3369,3370],{},"R1b-L21 is the genetic backbone of the populations that spoke the Celtic languages — Gaelic, Welsh, Cornish, Breton — and who built the hillforts, carved the La Tene metalwork, and painted themselves blue and screamed at the Roman legions across the length of Britain.",[20,3372,3373],{},"If you carry it, you are connected — in an unbroken patrilineal line — to men who were doing exactly that.",[33,3375],{},[15,3377,3379],{"id":3378},"how-haplogroups-work","How Haplogroups Work",[20,3381,3382],{},"Before understanding R1b-L21, it helps to understand what a haplogroup is and why it matters for ancestry research.",[20,3384,3385],{},"Your Y-chromosome is inherited from your father, who inherited it from his father, who inherited it from his father — in an unbroken chain stretching back through every generation to the first human males. The Y-chromosome passes from father to son with almost no genetic recombination. It's essentially a photocopy, generation after generation.",[20,3387,3388,3389,3392,3393,3396],{},"But not a perfect photocopy. Occasionally — roughly once every 80 to 145 years — a single nucleotide in the Y-chromosome copies incorrectly. This creates a ",[57,3390,3391],{},"mutation",", also called a ",[57,3394,3395],{},"SNP"," (Single Nucleotide Polymorphism). Once a mutation occurs, it is faithfully passed to all subsequent male descendants. No one else carries it (unless it occurred independently, which is extremely rare).",[20,3398,3399,3400,3403],{},"Geneticists use these accumulated mutations as ",[57,3401,3402],{},"chapter markers",". Each mutation defines a haplogroup — a group of related men who share a common patrilineal ancestor in whom that mutation first occurred.",[20,3405,3406,3408],{},[57,3407,3344],{}," is defined by the SNP called L21 (also known as S145 or M529). It means: every man who carries L21 descends from a single man — somewhere in the Bronze Age British Isles or Atlantic Europe — in whom this mutation first occurred.",[33,3410],{},[15,3412,3414],{"id":3413},"the-mutation-chain-r-to-r1b-l21","The Mutation Chain: R to R1b-L21",[20,3416,3417],{},"The full ancestry of R1b-L21 runs backward through a nested sequence of mutations, each one older than the last:",[20,3419,3420,3423],{},[57,3421,3422],{},"L21"," — the Atlantic Celtic marker; arose in Atlantic Europe/Britain, c. 3,500–4,000 years ago",[20,3425,3426,3429],{},[57,3427,3428],{},"P312"," — the parent clade; includes L21 and its sister clades (U152 in Italy/France, DF27 in Iberia). Arose in Atlantic Europe during the Bell Beaker expansion, c. 4,500 years ago",[20,3431,3432,3435],{},[57,3433,3434],{},"M269"," — the Western European clade; includes virtually all R1b in Western Europe. Arose on the Pontic-Caspian Steppe, c. 6,000–7,000 years ago",[20,3437,3438,3441],{},[57,3439,3440],{},"M343"," — defines R1b itself; arose c. 22,000 years ago during the Last Glacial Maximum",[20,3443,3444,3447],{},[57,3445,3446],{},"M207"," — defines haplogroup R; arose c. 28,000 years ago in Central Asia",[20,3449,3450,3453],{},[57,3451,3452],{},"M173"," — defines R1; arose c. 22,000–25,000 years ago",[20,3455,3456],{},"Each layer is a chapter in the genetic record. L21 is a relatively recent chapter — Bronze Age — layered on top of much older ancestry that stretches back to the Ice Age and beyond.",[33,3458],{},[15,3460,3462],{"id":3461},"where-r1b-l21-came-from","Where R1b-L21 Came From",[20,3464,3465],{},"The journey from the M207 mutation (28,000 years ago in Central Asia) to L21 (3,500–4,500 years ago in Atlantic Europe) spans the full arc of European prehistory.",[20,3467,3468,3471],{},[57,3469,3470],{},"The Ice Age refuge."," During the Last Glacial Maximum (26,500–19,000 years ago), R1b-carrying populations survived in refugia — pockets of habitable territory in southern Europe and the Caucasus region. Ancient DNA evidence suggests R1b-M343 populations were present in or near the Caucasus during this period.",[20,3473,3474,3477,3478,3481],{},[57,3475,3476],{},"The Steppe expansion."," Around 5,000–6,000 years ago, the R1b-M269 haplogroup expanded dramatically from the Pontic-Caspian Steppe with the ",[57,3479,3480],{},"Yamnaya culture"," — horse-riding, cattle-herding pastoralists who pushed into Europe from the east and north. The Yamnaya and their cultural successors (the Corded Ware culture) replaced the male lineages of Neolithic Europe with remarkable speed.",[20,3483,3484,3487,3488,3491],{},[57,3485,3486],{},"The Bell Beaker corridor."," The specific pathway to Ireland and Britain ran through the ",[57,3489,3490],{},"Bell Beaker phenomenon"," — a cultural and genetic complex that spread R1b-P312 (parent of L21) from Iberia through France, across the Channel, and into the British Isles between approximately 2,800 and 2,000 BC. Bell Beaker people brought R1b-L21 to Ireland around 2,500 BC.",[20,3493,3494,3497],{},[57,3495,3496],{},"The near-total replacement."," Ancient DNA from pre-Bell Beaker Ireland shows predominantly haplogroup I2 (an older hunter-gatherer and farmer marker). Post-Bell Beaker Ireland is overwhelmingly R1b-L21. The male lineage of Ireland's Bronze Age founders was replaced in a few centuries.",[20,3499,3500,3501,3505],{},"The Irish ",[3502,3503,3504],"em",{},"Lebor Gabála Érenn"," — the Book of Invasions — calls this the arrival of the sons of Míl Espáine, the Soldier of Spain. The DNA calls it the Bell Beaker expansion. The route was through Iberia. The myth got the geography right.",[33,3507],{},[15,3509,3511],{"id":3510},"the-major-subclades-of-r1b-l21","The Major Subclades of R1b-L21",[20,3513,3514],{},"L21 is not a single monolithic group. It has diversified into dozens of daughter subclades, some of which are closely associated with specific ethnic or geographic populations:",[20,3516,3517,3520,3521,3526,3527,3530],{},[57,3518,3519],{},"M222"," — the so-called \"Niall of the Nine Hostages\" subclade, concentrated in northwestern Ireland and among Dal Riata descendants in Scotland. First identified by ",[171,3522,3525],{"href":3523,"rel":3524},"https://doi.org/10.1086/507687",[175],"Emmeline Hill et al. (2006)"," in the ",[3502,3528,3529],{},"American Journal of Human Genetics",". High frequency in men with surnames like O'Neill, McLaughlin, Gallagher, O'Donnell, Doherty.",[20,3532,3533,3536],{},[57,3534,3535],{},"DF21"," — common in Scotland and Ireland; associated with Scottish Gaelic populations.",[20,3538,3539,3542],{},[57,3540,3541],{},"DF13"," — the parent of M222 and many other Celtic subclades; widespread in Ireland and Scotland.",[20,3544,3545,3548],{},[57,3546,3547],{},"DF49"," — another major branch; also widespread in the British Isles.",[20,3550,3551,3554],{},[57,3552,3553],{},"Z253"," — present in Ireland and Britain.",[20,3556,3557,3558,3561,3562,3565],{},"The absence of a specific subclade can be as informative as its presence. The Y-chromosome test of James R. Ross Jr. — haplogroup R1b-L21 — does ",[57,3559,3560],{},"not"," carry M222. The absence of M222 places the Ross patriline in a parallel branch of L21, diverging before the M222 mutation occurred — roughly 1,700–2,000 years ago. This is consistent with the traditional Ross genealogy's claim to descend from ",[57,3563,3564],{},"Loarn mac Eirc",", the elder brother of Fergus — a pre-M222 divergence from the main Irish royal dynasties.",[33,3567],{},[15,3569,3571],{"id":3570},"how-to-find-your-l21-subclade","How to Find Your L21 Subclade",[20,3573,3574,3575,3583,3584,3589],{},"If you're male and of Irish, Scottish, Welsh, or Atlantic European ancestry, the most informative test is a ",[57,3576,3577,3578],{},"Y-chromosome test through ",[171,3579,3582],{"href":3580,"rel":3581},"https://www.familytreedna.com",[175],"FamilyTreeDNA",". Their ",[171,3585,3588],{"href":3586,"rel":3587},"https://www.familytreedna.com/products/y-dna",[175],"Big Y-700 test"," sequences the Y-chromosome deeply enough to assign you to a precise subclade within L21 (or wherever you fall on the haplogroup tree).",[20,3591,3592],{},"Steps:",[1642,3594,3595,3602,3611,3614,3617],{},[188,3596,3597,3598],{},"Order a ",[171,3599,3601],{"href":3586,"rel":3600},[175],"Big Y-700 test at FamilyTreeDNA",[188,3603,3604,3605,3610],{},"Join the relevant surname DNA project (e.g., ",[171,3606,3609],{"href":3607,"rel":3608},"https://www.familytreedna.com/groups/ross/about",[175],"Ross Surname DNA Project",") or geographic project (Scottish, Irish, Welsh, etc.)",[188,3612,3613],{},"Review your haplogroup terminal SNP — this will be the most specific marker you carry",[188,3615,3616],{},"Compare against other project members to identify which clade you belong to",[188,3618,3619],{},"Look for M222 in your results to assess likely Niall of the Nine Hostages descent",[20,3621,3622],{},"Basic Y-37 and Y-111 tests will give you haplogroup information but with less resolution than the Big Y-700. For serious genealogical research, Big Y-700 is the gold standard.",[33,3624],{},[15,3626,3628],{"id":3627},"what-r1b-l21-does-and-doesnt-tell-you","What R1b-L21 Does and Doesn't Tell You",[20,3630,3631],{},"R1b-L21 is a patrilineal marker — it traces only the direct male line. Father's father's father, all the way back. It tells you nothing about:",[185,3633,3634,3637,3640,3643],{},[188,3635,3636],{},"Your maternal ancestry (mitochondrial DNA does that)",[188,3638,3639],{},"Your father's mother's line",[188,3641,3642],{},"Your mother's family",[188,3644,3645],{},"Autosomal ancestry (ethnic percentages, etc.)",[20,3647,3648],{},"What it does tell you is the specific patrilineal lineage you belong to — and for men with R1b-L21, that lineage runs back through the Celtic-speaking Atlantic world, through the Bell Beaker expansion, through the Yamnaya steppe, to the Ice Age.",[20,3650,3651],{},"It also tells you, specifically, which sub-branch of the Atlantic Celtic world your patriline represents. M222? You're in the Uí Néill cluster. DF21? Scottish Gaelic. DF13 without M222? Potentially older Irish or Dal Riata lineages. Each subclade narrows the geographic and cultural origin of your direct male line.",[33,3653],{},[15,3655,3657],{"id":3656},"r1b-l21-and-the-ross-line","R1b-L21 and the Ross Line",[20,3659,3660],{},"The Ross patriline is R1b-L21, without M222. Within the L21 family, this positions the Ross line as:",[185,3662,3663,3666,3669],{},[188,3664,3665],{},"Definitely of Atlantic Celtic origin (the Bell Beaker / Gaelic world)",[188,3667,3668],{},"Outside the Uí Néill dynasty (no M222)",[188,3670,3671],{},"In a parallel branch that diverged from the M222 clade before the Uí Néill ascendancy",[20,3673,3674],{},"The traditional Ross genealogy traces the line through Loarn mac Eirc — the elder brother of Fergus, the founding king of the Scottish Dal Riata. The DNA is consistent with an ancient Irish/Dal Riata origin that predates the M222 dynasty's dominance.",[20,3676,3677],{},"L21 without M222 doesn't pin the line to a specific historical family. But it does confirm the broad pattern: Atlantic Celtic origin, Dal Riata-era Scotland, pre-Uí Néill divergence.",[20,3679,3680,3681,3683,3684,582],{},"For a full analysis of what the R1b-L21 result means for the Ross family specifically — including the interpretation of each mutation in the haplogroup string and how it maps onto the ",[3502,3682,3504],{}," narrative — that argument is made across 46 chapters in ",[3502,3685,3686],{},"The Forge of Tongues",[20,3688,3689],{},[171,3690,3692],{"href":3691},"/book","Read more about what R1b-L21 means for Highland Scottish ancestry.",[33,3694],{},[15,3696,3698],{"id":3697},"key-facts-r1b-l21","Key Facts: R1b-L21",[3700,3701,3702,3713],"table",{},[3703,3704,3705],"thead",{},[3706,3707,3708,3711],"tr",{},[3709,3710],"th",{},[3709,3712],{},[3714,3715,3716,3727,3737,3747,3757,3767,3777,3787,3797],"tbody",{},[3706,3717,3718,3724],{},[3719,3720,3721],"td",{},[57,3722,3723],{},"Also known as",[3719,3725,3726],{},"S145, M529",[3706,3728,3729,3734],{},[3719,3730,3731],{},[57,3732,3733],{},"Parent clade",[3719,3735,3736],{},"R1b-P312",[3706,3738,3739,3744],{},[3719,3740,3741],{},[57,3742,3743],{},"Age",[3719,3745,3746],{},"c. 3,500–4,500 years before present",[3706,3748,3749,3754],{},[3719,3750,3751],{},[57,3752,3753],{},"Origin region",[3719,3755,3756],{},"Atlantic Europe / British Isles (Bell Beaker expansion)",[3706,3758,3759,3764],{},[3719,3760,3761],{},[57,3762,3763],{},"Frequency in Ireland",[3719,3765,3766],{},"~80% of men",[3706,3768,3769,3774],{},[3719,3770,3771],{},[57,3772,3773],{},"Frequency in Scotland (Highlands)",[3719,3775,3776],{},"~75–80% of men",[3706,3778,3779,3784],{},[3719,3780,3781],{},[57,3782,3783],{},"Frequency in Wales",[3719,3785,3786],{},"~80–85% of men",[3706,3788,3789,3794],{},[3719,3790,3791],{},[57,3792,3793],{},"Key subclades",[3719,3795,3796],{},"M222 (Uí Néill), DF21, DF13, DF49, Z253",[3706,3798,3799,3804],{},[3719,3800,3801],{},[57,3802,3803],{},"Testing",[3719,3805,3806],{},"FamilyTreeDNA Big Y-700 (most detailed)",[20,3808,3809],{},"If you carry R1b-L21, you share a patrilineal ancestor with tens of millions of men across the Atlantic world. The chain runs back through Bronze Age Ireland and Britain, through the Bell Beaker expansion in Iberia, through the Yamnaya steppe pastoralists, to a single man in Central Asia 22,000 years ago.",[20,3811,3812],{},"That chain is the oldest document your family possesses.",[33,3814],{},[15,3816,3818],{"id":3817},"related-articles","Related Articles",[185,3820,3821,3827,3833,3839],{},[188,3822,3823],{},[171,3824,3826],{"href":3825},"/blog/yamnaya-horizon-steppe-ancestors","The Yamnaya Horizon: The Steppe Pastoralists Who Rewrote European DNA",[188,3828,3829],{},[171,3830,3832],{"href":3831},"/blog/bell-beaker-conquest-ireland-britain","The Bell Beaker Conquest: How Bronze Age Migrants Replaced Ireland's Men",[188,3834,3835],{},[171,3836,3838],{"href":3837},"/blog/dal-riata-irish-kingdom-created-scotland","Dal Riata: The Irish Kingdom That Created Scotland",[188,3840,3841],{},[171,3842,3844],{"href":3843},"/blog/niall-of-the-nine-hostages-ross-connection","Niall of the Nine Hostages and the Ross Connection",{"title":213,"searchDepth":214,"depth":214,"links":3846},[3847,3848,3849,3850,3851,3852,3853,3854,3855,3856],{"id":3337,"depth":217,"text":3338},{"id":3378,"depth":217,"text":3379},{"id":3413,"depth":217,"text":3414},{"id":3461,"depth":217,"text":3462},{"id":3510,"depth":217,"text":3511},{"id":3570,"depth":217,"text":3571},{"id":3627,"depth":217,"text":3628},{"id":3656,"depth":217,"text":3657},{"id":3697,"depth":217,"text":3698},{"id":3817,"depth":217,"text":3818},"Heritage","R1b-L21 is the most common Y-chromosome haplogroup in Ireland, Scotland, Wales, and Brittany. If you have Highland or Irish ancestry, you probably carry it. Here's what it means, where it came from, and how to read your own results.",[3860,3861,3862,3863,3864,3865,3866],"r1b-l21","r1b l21 haplogroup","atlantic celtic haplogroup","haplogroup r1b western europe","celtic dna ancestry","y chromosome haplogroup","genetic genealogy scotland",{},"/blog/r1b-l21-atlantic-celtic-haplogroup",{"title":3330,"description":3858},"blog/r1b-l21-atlantic-celtic-haplogroup",[3344,3872,3873,3874,3875,3876],"Haplogroup","Genetic Genealogy","Celtic DNA","Scottish Ancestry","Clan Ross","XIMR7KB_wmfZk7YyyRNNCY3dbOiMinv4fWuNDnTs1ts",{"id":3879,"title":3880,"author":3881,"body":3882,"category":3313,"date":225,"description":4145,"extension":227,"featured":228,"image":229,"keywords":4146,"meta":4149,"navigation":234,"path":4150,"readTime":320,"seo":4151,"stem":4152,"tags":4153,"__hash__":4158},"blog/blog/rag-retrieval-augmented-generation.md","RAG (Retrieval-Augmented Generation): Building Smarter AI Applications",{"name":9,"bio":10},{"type":12,"value":3883,"toc":4124},[3884,3888,3891,3894,3897,3900,3902,3906,3909,3913,3916,3922,3928,3934,3940,3944,3947,3950,3961,3965,3976,3979,3981,3985,3989,3992,3995,3998,4002,4005,4008,4012,4015,4026,4029,4033,4036,4039,4041,4045,4049,4052,4055,4059,4062,4065,4069,4072,4075,4077,4081,4084,4087,4090,4098,4100,4102],[15,3885,3887],{"id":3886},"why-rag-exists-and-why-it-matters","Why RAG Exists and Why It Matters",[20,3889,3890],{},"Language models know a lot. They were trained on enormous amounts of text and internalized patterns, facts, and reasoning capabilities from that training. But their knowledge has a cutoff date, they don't know about your company's specific data, and when they're uncertain they sometimes generate plausible-sounding but incorrect answers.",[20,3892,3893],{},"Retrieval-Augmented Generation solves these problems by changing the fundamental approach: instead of the model answering purely from training-time knowledge, you retrieve relevant documents from your knowledge base and put them in the model's context at inference time. The model answers based on the retrieved content.",[20,3895,3896],{},"The result is a system that can answer questions about your specific, up-to-date knowledge base — not just general world knowledge — and can ground its answers in citable sources rather than opaque parametric memory.",[20,3898,3899],{},"RAG is the architectural pattern behind most useful enterprise AI applications: internal knowledge bases, customer support chatbots, document Q&A systems, contract analysis tools. If you're building AI that needs to know about specific information rather than just general world knowledge, you're probably building RAG.",[33,3901],{},[15,3903,3905],{"id":3904},"how-rag-works-the-complete-picture","How RAG Works: The Complete Picture",[20,3907,3908],{},"Most explanations of RAG stop at \"retrieve documents, put them in the prompt.\" The full picture is more nuanced, and the details matter for building systems that actually work.",[3151,3910,3912],{"id":3911},"the-ingestion-pipeline","The Ingestion Pipeline",[20,3914,3915],{},"Before retrieval can happen, you need to process and index your documents. This involves:",[20,3917,3918,3921],{},[57,3919,3920],{},"Text extraction",": Getting clean text from your source documents (PDFs, Word files, web pages, databases). The quality of your extracted text directly affects retrieval quality. Noisy text with OCR errors, HTML artifacts, or formatting garbage produces poor embeddings.",[20,3923,3924,3927],{},[57,3925,3926],{},"Chunking",": Splitting documents into retrievable units. This is one of the most consequential decisions in RAG architecture. Too small, and individual chunks lack enough context to be useful. Too large, and you're stuffing irrelevant content into the model's context. There's no universal right answer — the optimal chunk size depends on your document types and query patterns.",[20,3929,3930,3933],{},[57,3931,3932],{},"Embedding",": Converting each chunk into a vector representation using an embedding model. The embedding model captures semantic meaning — chunks with similar meaning get similar vectors.",[20,3935,3936,3939],{},[57,3937,3938],{},"Storage",": Persisting the chunks and their vectors in a vector store (pgvector, Pinecone, Weaviate, etc.) alongside metadata for filtering.",[3151,3941,3943],{"id":3942},"the-retrieval-step","The Retrieval Step",[20,3945,3946],{},"At query time, the user's question is embedded using the same embedding model, then used to query the vector store for the most semantically similar chunks. You typically retrieve the top 5-20 chunks, depending on context window budget and how much relevant information you need.",[20,3948,3949],{},"This is where a lot of RAG systems underperform. Pure vector similarity retrieval has limitations:",[185,3951,3952,3955,3958],{},[188,3953,3954],{},"It can miss relevant chunks if the query and chunk use different terminology for the same concept",[188,3956,3957],{},"It doesn't inherently respect document structure or relationships",[188,3959,3960],{},"It can retrieve contextually relevant but ultimately unhelpful chunks that sound similar but don't contain the needed information",[3151,3962,3964],{"id":3963},"the-augmented-generation-step","The Augmented Generation Step",[20,3966,3967,3968,3971,3972,3975],{},"The retrieved chunks are formatted into the model's context, typically with clear demarcation: \"Here are relevant documents: ",[272,3969,3970],{},"chunks",". Based on these documents, answer the following question: ",[272,3973,3974],{},"user question",".\"",[20,3977,3978],{},"The model then generates a response grounded in the retrieved content. With good system prompt design, you can instruct the model to cite its sources, acknowledge when the retrieved documents don't contain sufficient information, and refuse to speculate beyond what the documents contain.",[33,3980],{},[15,3982,3984],{"id":3983},"the-design-decisions-that-determine-rag-quality","The Design Decisions That Determine RAG Quality",[3151,3986,3988],{"id":3987},"chunking-strategy-is-everything","Chunking Strategy Is Everything",[20,3990,3991],{},"I've seen RAG systems that failed not because of the model or the retrieval algorithm but because of poor chunking. The chunks were too small to be coherent, or cut at paragraph boundaries that broke semantic units, or were too large to be specific.",[20,3993,3994],{},"My default approach: chunk at semantic boundaries (paragraphs, sections, list items) rather than fixed character counts. Overlap chunks by 10-20% to preserve context across boundaries. For structured documents (articles, documentation), use the document's natural structure (sections, subsections) as chunking boundaries.",[20,3996,3997],{},"For specialized document types, invest in custom chunking logic. A legal contract has different semantic structure than a product manual. Generic chunking strategies may miss what matters.",[3151,3999,4001],{"id":4000},"metadata-filtering-is-as-important-as-vector-similarity","Metadata Filtering Is as Important as Vector Similarity",[20,4003,4004],{},"Pure vector similarity retrieval is a blunt instrument. You almost always want to filter by metadata alongside similarity: retrieve documents from this time range, from this department, matching this document type, in this language.",[20,4006,4007],{},"Design your metadata schema before you build your indexing pipeline. Think about what dimensions users will need to filter by and ensure that metadata is captured and stored at index time. Retrofitting metadata to an existing index is painful.",[3151,4009,4011],{"id":4010},"hybrid-search-often-outperforms-pure-vector-search","Hybrid Search Often Outperforms Pure Vector Search",[20,4013,4014],{},"In production RAG systems, I often use hybrid search — combining vector similarity with keyword search (BM25 or similar) and using a reciprocal rank fusion or reranking step to combine the results. This works better than pure vector search for several reasons:",[185,4016,4017,4020,4023],{},[188,4018,4019],{},"Keyword search is more precise for technical terms, product codes, and proper nouns",[188,4021,4022],{},"Vector search catches semantic similarity that keyword search misses",[188,4024,4025],{},"The combination captures both precision and recall",[20,4027,4028],{},"The added complexity is worth it for production systems. The quality improvement is meaningful.",[3151,4030,4032],{"id":4031},"reranking-before-generation","Reranking Before Generation",[20,4034,4035],{},"Retrieving the top-20 similar chunks and feeding all of them into the context is inefficient and often counterproductive. A reranking step — using a smaller model to score the retrieved chunks for relevance to the specific query — lets you select the best 3-5 chunks rather than taking the raw top-k results.",[20,4037,4038],{},"Cross-encoder rerankers (models trained specifically to assess query-document relevance) are more accurate than the initial bi-encoder retrieval. This two-stage approach (fast retrieval, accurate reranking) is a common pattern in production RAG systems.",[33,4040],{},[15,4042,4044],{"id":4043},"common-rag-failures-and-how-to-avoid-them","Common RAG Failures and How to Avoid Them",[3151,4046,4048],{"id":4047},"the-lost-in-the-middle-problem","The \"Lost in the Middle\" Problem",[20,4050,4051],{},"Research has shown that language models are worse at using information from the middle of long contexts than from the beginning or end. If you're stuffing 20 retrieved chunks into a context, the information in chunks 10-15 may be underutilized relative to information in chunks 1-2 and 18-20.",[20,4053,4054],{},"Mitigation: don't retrieve more than you need, rerank to put the most relevant chunks first, and use prompt techniques that instruct the model to consider all provided context.",[3151,4056,4058],{"id":4057},"hallucinations-on-edge-cases","Hallucinations on Edge Cases",[20,4060,4061],{},"RAG doesn't eliminate hallucination — it just changes the character of the failure. Instead of making up facts from parametric memory, a model in a RAG system can misinterpret retrieved documents, incorrectly synthesize information from multiple chunks, or hallucinate details that aren't in the retrieved content.",[20,4063,4064],{},"Mitigation: require the model to cite specific passages from retrieved documents, instruct the model to say \"this information is not in the provided documents\" when retrieval doesn't cover the query, and validate critical outputs against the source documents programmatically.",[3151,4066,4068],{"id":4067},"retrieval-that-finds-semantically-similar-but-contextually-wrong-content","Retrieval That Finds Semantically Similar But Contextually Wrong Content",[20,4070,4071],{},"Vector similarity is semantic, not contextual. A query about Q4 revenue might retrieve a document about Q4 inventory, which is semantically similar but contextually irrelevant. This is the retrieval precision problem.",[20,4073,4074],{},"Mitigation: better metadata filtering (filter by document type, date, department), more specific chunking that preserves document context, and reranking that accounts for full query context not just keyword similarity.",[33,4076],{},[15,4078,4080],{"id":4079},"when-rag-is-and-isnt-the-right-pattern","When RAG Is and Isn't the Right Pattern",[20,4082,4083],{},"RAG is the right pattern when: you need to query a specific, potentially large knowledge base; that knowledge base changes frequently (RAG requires no retraining); you need citeable, grounded answers; and your knowledge base is too large to fit in a context window directly.",[20,4085,4086],{},"RAG is not the right pattern when: your knowledge base is small enough to fit in context (just include it); the domain knowledge is stable enough to fine-tune on; or the query requires complex multi-hop reasoning across documents (RAG retrieval is typically single-hop — it finds relevant chunks but doesn't reason across document relationships).",[20,4088,4089],{},"RAG has become a default answer to \"how do I build AI on my data\" — and for many cases it is the right default. But it's not the only answer and it's not always the best one. Know why you're choosing it.",[20,4091,4092,4093,4097],{},"If you're designing a RAG system and want to think through the architecture before committing to implementation, ",[171,4094,4096],{"href":173,"rel":4095},[175],"schedule a consultation at Calendly",". Getting the retrieval architecture right from the start saves weeks of debugging poor quality answers later.",[33,4099],{},[15,4101,183],{"id":182},[185,4103,4104,4108,4114,4120],{},[188,4105,4106],{},[171,4107,3279],{"href":3278},[188,4109,4110],{},[171,4111,4113],{"href":4112},"/blog/vector-databases-explained","Vector Databases Explained: When You Need Them and When You Don't",[188,4115,4116],{},[171,4117,4119],{"href":4118},"/blog/ai-ethics-enterprise-software","AI Ethics in Enterprise Software: The Practical Side of Responsible AI",[188,4121,4122],{},[171,4123,3285],{"href":3284},{"title":213,"searchDepth":214,"depth":214,"links":4125},[4126,4127,4132,4138,4143,4144],{"id":3886,"depth":217,"text":3887},{"id":3904,"depth":217,"text":3905,"children":4128},[4129,4130,4131],{"id":3911,"depth":214,"text":3912},{"id":3942,"depth":214,"text":3943},{"id":3963,"depth":214,"text":3964},{"id":3983,"depth":217,"text":3984,"children":4133},[4134,4135,4136,4137],{"id":3987,"depth":214,"text":3988},{"id":4000,"depth":214,"text":4001},{"id":4010,"depth":214,"text":4011},{"id":4031,"depth":214,"text":4032},{"id":4043,"depth":217,"text":4044,"children":4139},[4140,4141,4142],{"id":4047,"depth":214,"text":4048},{"id":4057,"depth":214,"text":4058},{"id":4067,"depth":214,"text":4068},{"id":4079,"depth":217,"text":4080},{"id":182,"depth":217,"text":183},"A developer's practical guide to Retrieval-Augmented Generation — how RAG works, when to use it, how to design it well, and the common mistakes that kill RAG quality.",[4147,4148],"RAG retrieval augmented generation","LLM application development",{},"/blog/rag-retrieval-augmented-generation",{"title":3880,"description":4145},"blog/rag-retrieval-augmented-generation",[4154,3324,4155,4156,4157],"RAG","AI Architecture","Vector Databases","AI Development","dF93UDiwNejUdPRJWyxHXPI_MlnpA7UcOJmY89oogXA",{"id":4160,"title":4161,"author":4162,"body":4163,"category":2154,"date":225,"description":6484,"extension":227,"featured":228,"image":229,"keywords":6485,"meta":6488,"navigation":234,"path":6489,"readTime":236,"seo":6490,"stem":6491,"tags":6492,"__hash__":6496},"blog/blog/redis-caching-guide.md","Redis Caching Strategies: When and How to Cache in Production",{"name":9,"bio":10},{"type":12,"value":4164,"toc":6472},[4165,4168,4171,4175,4178,4189,4192,4203,4206,4210,4447,4454,4458,4461,4714,4717,4834,4837,4841,4844,4967,4970,4974,4977,4980,5018,5021,5314,5318,5321,5328,5363,5366,5745,5749,5752,5867,5870,5881,5885,5888,6185,6188,6192,6198,6204,6349,6355,6430,6433,6435,6441,6443,6445,6469],[20,4166,4167],{},"Caching is one of those tools that can make your system faster and more reliable, or introduce subtle bugs that are extremely hard to debug. The difference is understanding what you are actually caching, for how long, and what happens when the cached data becomes stale or wrong.",[20,4169,4170],{},"I have shipped systems where caching cut API response times from 400ms to 8ms. I have also inherited systems where caching bugs caused users to see each other's data. Here is how to get the former and avoid the latter.",[15,4172,4174],{"id":4173},"when-to-cache","When to Cache",[20,4176,4177],{},"Caching helps when:",[185,4179,4180,4183,4186],{},[188,4181,4182],{},"The computation or database query is expensive and the result is used frequently",[188,4184,4185],{},"The data does not need to be perfectly fresh for every read",[188,4187,4188],{},"The access pattern is read-heavy with infrequent writes",[20,4190,4191],{},"Caching does not help (and adds complexity) when:",[185,4193,4194,4197,4200],{},[188,4195,4196],{},"The data changes frequently enough that cached values are usually stale",[188,4198,4199],{},"The query is fast enough that cache overhead is significant in proportion",[188,4201,4202],{},"Correctness requires every read to see the latest data",[20,4204,4205],{},"A good rule: start without caching, measure, and add caching where you have profiled evidence that it helps. Premature caching is a source of bugs without corresponding benefits.",[15,4207,4209],{"id":4208},"connecting-to-redis-with-ioredis","Connecting to Redis With ioredis",[264,4211,4213],{"className":606,"code":4212,"language":608,"meta":213,"style":213},"import Redis from 'ioredis'\n\nConst redis = new Redis({\n host: process.env.REDIS_HOST!,\n port: Number(process.env.REDIS_PORT ?? 6379),\n password: process.env.REDIS_PASSWORD,\n tls: process.env.NODE_ENV === 'production' ? {} : undefined,\n retryStrategy: (times) => {\n const delay = Math.min(times * 50, 2000)\n return delay\n },\n maxRetriesPerRequest: 3,\n})\n\nRedis.on('error', (err) => {\n console.error('Redis connection error:', err)\n})\n\nExport default redis\n",[95,4214,4215,4227,4231,4245,4258,4281,4291,4314,4331,4362,4369,4373,4383,4387,4391,4414,4429,4433,4437],{"__ignoreMap":213},[272,4216,4217,4219,4222,4224],{"class":274,"line":275},[272,4218,622],{"class":621},[272,4220,4221],{"class":625}," Redis ",[272,4223,629],{"class":621},[272,4225,4226],{"class":632}," 'ioredis'\n",[272,4228,4229],{"class":274,"line":217},[272,4230,300],{"emptyLinePlaceholder":234},[272,4232,4233,4236,4238,4240,4243],{"class":274,"line":214},[272,4234,4235],{"class":625},"Const redis ",[272,4237,645],{"class":621},[272,4239,714],{"class":621},[272,4241,4242],{"class":673}," Redis",[272,4244,719],{"class":625},[272,4246,4247,4250,4253,4256],{"class":274,"line":291},[272,4248,4249],{"class":625}," host: process.env.",[272,4251,4252],{"class":654},"REDIS_HOST",[272,4254,4255],{"class":621},"!",[272,4257,924],{"class":625},[272,4259,4260,4263,4266,4269,4272,4275,4278],{"class":274,"line":297},[272,4261,4262],{"class":625}," port: ",[272,4264,4265],{"class":673},"Number",[272,4267,4268],{"class":625},"(process.env.",[272,4270,4271],{"class":654},"REDIS_PORT",[272,4273,4274],{"class":621}," ??",[272,4276,4277],{"class":654}," 6379",[272,4279,4280],{"class":625},"),\n",[272,4282,4283,4286,4289],{"class":274,"line":303},[272,4284,4285],{"class":625}," password: process.env.",[272,4287,4288],{"class":654},"REDIS_PASSWORD",[272,4290,924],{"class":625},[272,4292,4293,4296,4298,4300,4302,4304,4307,4309,4312],{"class":274,"line":236},[272,4294,4295],{"class":625}," tls: process.env.",[272,4297,732],{"class":654},[272,4299,735],{"class":621},[272,4301,800],{"class":632},[272,4303,743],{"class":621},[272,4305,4306],{"class":625}," {} ",[272,4308,670],{"class":621},[272,4310,4311],{"class":654}," undefined",[272,4313,924],{"class":625},[272,4315,4316,4319,4322,4325,4327,4329],{"class":274,"line":314},[272,4317,4318],{"class":673}," retryStrategy",[272,4320,4321],{"class":625},": (",[272,4323,4324],{"class":666},"times",[272,4326,1300],{"class":625},[272,4328,1303],{"class":621},[272,4330,661],{"class":625},[272,4332,4333,4335,4338,4340,4343,4346,4349,4352,4355,4357,4360],{"class":274,"line":320},[272,4334,1094],{"class":621},[272,4336,4337],{"class":654}," delay",[272,4339,858],{"class":621},[272,4341,4342],{"class":625}," Math.",[272,4344,4345],{"class":673},"min",[272,4347,4348],{"class":625},"(times ",[272,4350,4351],{"class":621},"*",[272,4353,4354],{"class":654}," 50",[272,4356,752],{"class":625},[272,4358,4359],{"class":654},"2000",[272,4361,1368],{"class":625},[272,4363,4364,4366],{"class":274,"line":325},[272,4365,1946],{"class":621},[272,4367,4368],{"class":625}," delay\n",[272,4370,4371],{"class":274,"line":330},[272,4372,879],{"class":625},[272,4374,4375,4378,4381],{"class":274,"line":336},[272,4376,4377],{"class":625}," maxRetriesPerRequest: ",[272,4379,4380],{"class":654},"3",[272,4382,924],{"class":625},[272,4384,4385],{"class":274,"line":342},[272,4386,884],{"class":625},[272,4388,4389],{"class":274,"line":348},[272,4390,300],{"emptyLinePlaceholder":234},[272,4392,4393,4396,4398,4400,4402,4405,4408,4410,4412],{"class":274,"line":354},[272,4394,4395],{"class":625},"Redis.",[272,4397,2776],{"class":673},[272,4399,1290],{"class":625},[272,4401,760],{"class":632},[272,4403,4404],{"class":625},", (",[272,4406,4407],{"class":666},"err",[272,4409,1300],{"class":625},[272,4411,1303],{"class":621},[272,4413,661],{"class":625},[272,4415,4416,4418,4421,4423,4426],{"class":274,"line":360},[272,4417,1887],{"class":625},[272,4419,4420],{"class":673},"error",[272,4422,1290],{"class":625},[272,4424,4425],{"class":632},"'Redis connection error:'",[272,4427,4428],{"class":625},", err)\n",[272,4430,4431],{"class":274,"line":366},[272,4432,884],{"class":625},[272,4434,4435],{"class":274,"line":372},[272,4436,300],{"emptyLinePlaceholder":234},[272,4438,4439,4441,4444],{"class":274,"line":378},[272,4440,693],{"class":625},[272,4442,4443],{"class":621},"default",[272,4445,4446],{"class":625}," redis\n",[20,4448,4449,4450,4453],{},"The ",[95,4451,4452],{},"retryStrategy"," ensures temporary Redis failures do not crash your application — it retries with exponential backoff. Design your application to degrade gracefully when Redis is unavailable.",[15,4455,4457],{"id":4456},"cache-aside-pattern","Cache-Aside Pattern",[20,4459,4460],{},"The most common caching pattern. The application manages the cache explicitly:",[264,4462,4464],{"className":606,"code":4463,"language":608,"meta":213,"style":213},"async function getUser(userId: string): Promise\u003CUser> {\n const cacheKey = `user:${userId}`\n\n // Try cache first\n const cached = await redis.get(cacheKey)\n if (cached) {\n return JSON.parse(cached) as User\n }\n\n // Cache miss: fetch from database\n const user = await prisma.user.findUniqueOrThrow({\n where: { id: userId },\n select: {\n id: true,\n name: true,\n email: true,\n role: true,\n createdAt: true,\n },\n })\n\n // Store in cache with TTL\n await redis.setex(cacheKey, 300, JSON.stringify(user)) // 5 minutes\n\n return user\n}\n",[95,4465,4466,4497,4514,4518,4523,4543,4550,4570,4574,4578,4583,4600,4605,4609,4617,4625,4634,4643,4651,4655,4659,4663,4668,4699,4703,4710],{"__ignoreMap":213},[272,4467,4468,4470,4472,4475,4477,4480,4482,4484,4486,4488,4490,4492,4495],{"class":274,"line":275},[272,4469,1216],{"class":621},[272,4471,1219],{"class":621},[272,4473,4474],{"class":673}," getUser",[272,4476,1290],{"class":625},[272,4478,4479],{"class":666},"userId",[272,4481,670],{"class":621},[272,4483,1235],{"class":654},[272,4485,1263],{"class":625},[272,4487,670],{"class":621},[272,4489,1268],{"class":673},[272,4491,1271],{"class":625},[272,4493,4494],{"class":673},"User",[272,4496,1277],{"class":625},[272,4498,4499,4501,4504,4506,4509,4511],{"class":274,"line":217},[272,4500,1094],{"class":621},[272,4502,4503],{"class":654}," cacheKey",[272,4505,858],{"class":621},[272,4507,4508],{"class":632}," `user:${",[272,4510,4479],{"class":625},[272,4512,4513],{"class":632},"}`\n",[272,4515,4516],{"class":274,"line":214},[272,4517,300],{"emptyLinePlaceholder":234},[272,4519,4520],{"class":274,"line":291},[272,4521,4522],{"class":615}," // Try cache first\n",[272,4524,4525,4527,4530,4532,4534,4537,4540],{"class":274,"line":297},[272,4526,1094],{"class":621},[272,4528,4529],{"class":654}," cached",[272,4531,858],{"class":621},[272,4533,861],{"class":621},[272,4535,4536],{"class":625}," redis.",[272,4538,4539],{"class":673},"get",[272,4541,4542],{"class":625},"(cacheKey)\n",[272,4544,4545,4547],{"class":274,"line":303},[272,4546,1342],{"class":621},[272,4548,4549],{"class":625}," (cached) {\n",[272,4551,4552,4554,4557,4559,4562,4565,4567],{"class":274,"line":236},[272,4553,1946],{"class":621},[272,4555,4556],{"class":654}," JSON",[272,4558,582],{"class":625},[272,4560,4561],{"class":673},"parse",[272,4563,4564],{"class":625},"(cached) ",[272,4566,651],{"class":621},[272,4568,4569],{"class":673}," User\n",[272,4571,4572],{"class":274,"line":314},[272,4573,1373],{"class":625},[272,4575,4576],{"class":274,"line":320},[272,4577,300],{"emptyLinePlaceholder":234},[272,4579,4580],{"class":274,"line":325},[272,4581,4582],{"class":615}," // Cache miss: fetch from database\n",[272,4584,4585,4587,4590,4592,4594,4596,4598],{"class":274,"line":330},[272,4586,1094],{"class":621},[272,4588,4589],{"class":654}," user",[272,4591,858],{"class":621},[272,4593,861],{"class":621},[272,4595,1104],{"class":625},[272,4597,1322],{"class":673},[272,4599,719],{"class":625},[272,4601,4602],{"class":274,"line":336},[272,4603,4604],{"class":625}," where: { id: userId },\n",[272,4606,4607],{"class":274,"line":342},[272,4608,914],{"class":625},[272,4610,4611,4613,4615],{"class":274,"line":348},[272,4612,919],{"class":625},[272,4614,876],{"class":654},[272,4616,924],{"class":625},[272,4618,4619,4621,4623],{"class":274,"line":354},[272,4620,973],{"class":625},[272,4622,876],{"class":654},[272,4624,924],{"class":625},[272,4626,4627,4630,4632],{"class":274,"line":360},[272,4628,4629],{"class":625}," email: ",[272,4631,876],{"class":654},[272,4633,924],{"class":625},[272,4635,4636,4639,4641],{"class":274,"line":366},[272,4637,4638],{"class":625}," role: ",[272,4640,876],{"class":654},[272,4642,924],{"class":625},[272,4644,4645,4647,4649],{"class":274,"line":372},[272,4646,947],{"class":625},[272,4648,876],{"class":654},[272,4650,924],{"class":625},[272,4652,4653],{"class":274,"line":378},[272,4654,879],{"class":625},[272,4656,4657],{"class":274,"line":384},[272,4658,780],{"class":625},[272,4660,4661],{"class":274,"line":390},[272,4662,300],{"emptyLinePlaceholder":234},[272,4664,4665],{"class":274,"line":396},[272,4666,4667],{"class":615}," // Store in cache with TTL\n",[272,4669,4670,4672,4674,4677,4680,4683,4685,4688,4690,4693,4696],{"class":274,"line":401},[272,4671,861],{"class":621},[272,4673,4536],{"class":625},[272,4675,4676],{"class":673},"setex",[272,4678,4679],{"class":625},"(cacheKey, ",[272,4681,4682],{"class":654},"300",[272,4684,752],{"class":625},[272,4686,4687],{"class":654},"JSON",[272,4689,582],{"class":625},[272,4691,4692],{"class":673},"stringify",[272,4694,4695],{"class":625},"(user)) ",[272,4697,4698],{"class":615},"// 5 minutes\n",[272,4700,4701],{"class":274,"line":407},[272,4702,300],{"emptyLinePlaceholder":234},[272,4704,4705,4707],{"class":274,"line":413},[272,4706,1946],{"class":621},[272,4708,4709],{"class":625}," user\n",[272,4711,4712],{"class":274,"line":418},[272,4713,294],{"class":625},[20,4715,4716],{},"When the user is updated, invalidate the cache:",[264,4718,4720],{"className":606,"code":4719,"language":608,"meta":213,"style":213},"async function updateUser(userId: string, data: UpdateUserInput): Promise\u003CUser> {\n const user = await prisma.user.update({\n where: { id: userId },\n data,\n })\n\n // Invalidate the cached entry\n await redis.del(`user:${userId}`)\n\n return user\n}\n",[95,4721,4722,4761,4777,4781,4786,4790,4794,4799,4820,4824,4830],{"__ignoreMap":213},[272,4723,4724,4726,4728,4731,4733,4735,4737,4739,4741,4744,4746,4749,4751,4753,4755,4757,4759],{"class":274,"line":275},[272,4725,1216],{"class":621},[272,4727,1219],{"class":621},[272,4729,4730],{"class":673}," updateUser",[272,4732,1290],{"class":625},[272,4734,4479],{"class":666},[272,4736,670],{"class":621},[272,4738,1235],{"class":654},[272,4740,752],{"class":625},[272,4742,4743],{"class":666},"data",[272,4745,670],{"class":621},[272,4747,4748],{"class":673}," UpdateUserInput",[272,4750,1263],{"class":625},[272,4752,670],{"class":621},[272,4754,1268],{"class":673},[272,4756,1271],{"class":625},[272,4758,4494],{"class":673},[272,4760,1277],{"class":625},[272,4762,4763,4765,4767,4769,4771,4773,4775],{"class":274,"line":217},[272,4764,1094],{"class":621},[272,4766,4589],{"class":654},[272,4768,858],{"class":621},[272,4770,861],{"class":621},[272,4772,1104],{"class":625},[272,4774,1386],{"class":673},[272,4776,719],{"class":625},[272,4778,4779],{"class":274,"line":214},[272,4780,4604],{"class":625},[272,4782,4783],{"class":274,"line":291},[272,4784,4785],{"class":625}," data,\n",[272,4787,4788],{"class":274,"line":297},[272,4789,780],{"class":625},[272,4791,4792],{"class":274,"line":303},[272,4793,300],{"emptyLinePlaceholder":234},[272,4795,4796],{"class":274,"line":236},[272,4797,4798],{"class":615}," // Invalidate the cached entry\n",[272,4800,4801,4803,4805,4808,4810,4813,4815,4818],{"class":274,"line":314},[272,4802,861],{"class":621},[272,4804,4536],{"class":625},[272,4806,4807],{"class":673},"del",[272,4809,1290],{"class":625},[272,4811,4812],{"class":632},"`user:${",[272,4814,4479],{"class":625},[272,4816,4817],{"class":632},"}`",[272,4819,1368],{"class":625},[272,4821,4822],{"class":274,"line":320},[272,4823,300],{"emptyLinePlaceholder":234},[272,4825,4826,4828],{"class":274,"line":325},[272,4827,1946],{"class":621},[272,4829,4709],{"class":625},[272,4831,4832],{"class":274,"line":330},[272,4833,294],{"class":625},[20,4835,4836],{},"This is the simplest correct implementation. The weakness is that all cached user data expires when any field changes — even if the requesting component only needed one field that did not change.",[15,4838,4840],{"id":4839},"write-through-cache","Write-Through Cache",[20,4842,4843],{},"Write-through keeps the cache up to date by writing to both cache and database on every write:",[264,4845,4847],{"className":606,"code":4846,"language":608,"meta":213,"style":213},"async function updateUser(userId: string, data: UpdateUserInput): Promise\u003CUser> {\n const user = await prisma.user.update({\n where: { id: userId },\n data,\n })\n\n // Update cache with new data (not just invalidate)\n await redis.setex(`user:${userId}`, 300, JSON.stringify(user))\n\n return user\n}\n",[95,4848,4849,4885,4901,4905,4909,4913,4917,4922,4953,4957,4963],{"__ignoreMap":213},[272,4850,4851,4853,4855,4857,4859,4861,4863,4865,4867,4869,4871,4873,4875,4877,4879,4881,4883],{"class":274,"line":275},[272,4852,1216],{"class":621},[272,4854,1219],{"class":621},[272,4856,4730],{"class":673},[272,4858,1290],{"class":625},[272,4860,4479],{"class":666},[272,4862,670],{"class":621},[272,4864,1235],{"class":654},[272,4866,752],{"class":625},[272,4868,4743],{"class":666},[272,4870,670],{"class":621},[272,4872,4748],{"class":673},[272,4874,1263],{"class":625},[272,4876,670],{"class":621},[272,4878,1268],{"class":673},[272,4880,1271],{"class":625},[272,4882,4494],{"class":673},[272,4884,1277],{"class":625},[272,4886,4887,4889,4891,4893,4895,4897,4899],{"class":274,"line":217},[272,4888,1094],{"class":621},[272,4890,4589],{"class":654},[272,4892,858],{"class":621},[272,4894,861],{"class":621},[272,4896,1104],{"class":625},[272,4898,1386],{"class":673},[272,4900,719],{"class":625},[272,4902,4903],{"class":274,"line":214},[272,4904,4604],{"class":625},[272,4906,4907],{"class":274,"line":291},[272,4908,4785],{"class":625},[272,4910,4911],{"class":274,"line":297},[272,4912,780],{"class":625},[272,4914,4915],{"class":274,"line":303},[272,4916,300],{"emptyLinePlaceholder":234},[272,4918,4919],{"class":274,"line":236},[272,4920,4921],{"class":615}," // Update cache with new data (not just invalidate)\n",[272,4923,4924,4926,4928,4930,4932,4934,4936,4938,4940,4942,4944,4946,4948,4950],{"class":274,"line":314},[272,4925,861],{"class":621},[272,4927,4536],{"class":625},[272,4929,4676],{"class":673},[272,4931,1290],{"class":625},[272,4933,4812],{"class":632},[272,4935,4479],{"class":625},[272,4937,4817],{"class":632},[272,4939,752],{"class":625},[272,4941,4682],{"class":654},[272,4943,752],{"class":625},[272,4945,4687],{"class":654},[272,4947,582],{"class":625},[272,4949,4692],{"class":673},[272,4951,4952],{"class":625},"(user))\n",[272,4954,4955],{"class":274,"line":320},[272,4956,300],{"emptyLinePlaceholder":234},[272,4958,4959,4961],{"class":274,"line":325},[272,4960,1946],{"class":621},[272,4962,4709],{"class":625},[272,4964,4965],{"class":274,"line":330},[272,4966,294],{"class":625},[20,4968,4969],{},"Write-through reduces cache miss rates at the cost of making writes slightly slower (two operations instead of one). For frequently-read, occasionally-written data (user profiles, configuration), this is a good trade.",[15,4971,4973],{"id":4972},"ttl-strategy","TTL Strategy",[20,4975,4976],{},"Setting the right TTL (Time To Live) is critical. Too short: high database load, frequent cache misses. Too long: stale data, potential correctness issues.",[20,4978,4979],{},"My TTL guidelines:",[185,4981,4982,4988,4994,5000,5006,5012],{},[188,4983,4984,4987],{},[57,4985,4986],{},"User sessions:"," 24 hours or the session duration",[188,4989,4990,4993],{},[57,4991,4992],{},"User profile data:"," 5-15 minutes (changes rarely, reads frequently)",[188,4995,4996,4999],{},[57,4997,4998],{},"Product catalog:"," 1-24 hours (changes infrequently, high read volume)",[188,5001,5002,5005],{},[57,5003,5004],{},"Search results:"," 5-60 minutes (depends on data update frequency)",[188,5007,5008,5011],{},[57,5009,5010],{},"API rate limit counters:"," Duration of the rate limit window",[188,5013,5014,5017],{},[57,5015,5016],{},"Computed analytics:"," 1-24 hours",[20,5019,5020],{},"For data where staleness is acceptable but you want freshness when possible, use a short TTL with background refresh:",[264,5022,5024],{"className":606,"code":5023,"language":608,"meta":213,"style":213},"async function getWithBackgroundRefresh\u003CT>(\n key: string,\n fetchFn: () => Promise\u003CT>,\n ttl: number\n): Promise\u003CT> {\n const [cached, ttlRemaining] = await Promise.all([\n redis.get(key),\n redis.ttl(key),\n ])\n\n if (cached) {\n const data = JSON.parse(cached) as T\n\n // Refresh in background when TTL is below 20%\n if (ttlRemaining \u003C ttl * 0.2) {\n fetchFn().then(fresh => redis.setex(key, ttl, JSON.stringify(fresh)))\n }\n\n return data\n }\n\n const fresh = await fetchFn()\n await redis.setex(key, ttl, JSON.stringify(fresh))\n return fresh\n}\n",[95,5025,5026,5043,5054,5075,5084,5098,5129,5138,5147,5152,5156,5162,5184,5188,5193,5212,5246,5250,5254,5261,5265,5269,5284,5303,5310],{"__ignoreMap":213},[272,5027,5028,5030,5032,5035,5037,5040],{"class":274,"line":275},[272,5029,1216],{"class":621},[272,5031,1219],{"class":621},[272,5033,5034],{"class":673}," getWithBackgroundRefresh",[272,5036,1271],{"class":625},[272,5038,5039],{"class":673},"T",[272,5041,5042],{"class":625},">(\n",[272,5044,5045,5048,5050,5052],{"class":274,"line":217},[272,5046,5047],{"class":666}," key",[272,5049,670],{"class":621},[272,5051,1235],{"class":654},[272,5053,924],{"class":625},[272,5055,5056,5059,5061,5064,5066,5068,5070,5072],{"class":274,"line":214},[272,5057,5058],{"class":673}," fetchFn",[272,5060,670],{"class":621},[272,5062,5063],{"class":625}," () ",[272,5065,1303],{"class":621},[272,5067,1268],{"class":673},[272,5069,1271],{"class":625},[272,5071,5039],{"class":673},[272,5073,5074],{"class":625},">,\n",[272,5076,5077,5080,5082],{"class":274,"line":291},[272,5078,5079],{"class":666}," ttl",[272,5081,670],{"class":621},[272,5083,1258],{"class":654},[272,5085,5086,5088,5090,5092,5094,5096],{"class":274,"line":297},[272,5087,1263],{"class":625},[272,5089,670],{"class":621},[272,5091,1268],{"class":673},[272,5093,1271],{"class":625},[272,5095,5039],{"class":673},[272,5097,1277],{"class":625},[272,5099,5100,5102,5104,5107,5109,5112,5115,5117,5119,5121,5123,5126],{"class":274,"line":303},[272,5101,1094],{"class":621},[272,5103,746],{"class":625},[272,5105,5106],{"class":654},"cached",[272,5108,752],{"class":625},[272,5110,5111],{"class":654},"ttlRemaining",[272,5113,5114],{"class":625},"] ",[272,5116,645],{"class":621},[272,5118,861],{"class":621},[272,5120,1268],{"class":654},[272,5122,582],{"class":625},[272,5124,5125],{"class":673},"all",[272,5127,5128],{"class":625},"([\n",[272,5130,5131,5133,5135],{"class":274,"line":236},[272,5132,4536],{"class":625},[272,5134,4539],{"class":673},[272,5136,5137],{"class":625},"(key),\n",[272,5139,5140,5142,5145],{"class":274,"line":314},[272,5141,4536],{"class":625},[272,5143,5144],{"class":673},"ttl",[272,5146,5137],{"class":625},[272,5148,5149],{"class":274,"line":320},[272,5150,5151],{"class":625}," ])\n",[272,5153,5154],{"class":274,"line":325},[272,5155,300],{"emptyLinePlaceholder":234},[272,5157,5158,5160],{"class":274,"line":330},[272,5159,1342],{"class":621},[272,5161,4549],{"class":625},[272,5163,5164,5166,5169,5171,5173,5175,5177,5179,5181],{"class":274,"line":336},[272,5165,1094],{"class":621},[272,5167,5168],{"class":654}," data",[272,5170,858],{"class":621},[272,5172,4556],{"class":654},[272,5174,582],{"class":625},[272,5176,4561],{"class":673},[272,5178,4564],{"class":625},[272,5180,651],{"class":621},[272,5182,5183],{"class":673}," T\n",[272,5185,5186],{"class":274,"line":342},[272,5187,300],{"emptyLinePlaceholder":234},[272,5189,5190],{"class":274,"line":348},[272,5191,5192],{"class":615}," // Refresh in background when TTL is below 20%\n",[272,5194,5195,5197,5200,5202,5205,5207,5210],{"class":274,"line":354},[272,5196,1342],{"class":621},[272,5198,5199],{"class":625}," (ttlRemaining ",[272,5201,1271],{"class":621},[272,5203,5204],{"class":625}," ttl ",[272,5206,4351],{"class":621},[272,5208,5209],{"class":654}," 0.2",[272,5211,803],{"class":625},[272,5213,5214,5216,5219,5222,5224,5227,5230,5232,5234,5237,5239,5241,5243],{"class":274,"line":360},[272,5215,5058],{"class":673},[272,5217,5218],{"class":625},"().",[272,5220,5221],{"class":673},"then",[272,5223,1290],{"class":625},[272,5225,5226],{"class":666},"fresh",[272,5228,5229],{"class":621}," =>",[272,5231,4536],{"class":625},[272,5233,4676],{"class":673},[272,5235,5236],{"class":625},"(key, ttl, ",[272,5238,4687],{"class":654},[272,5240,582],{"class":625},[272,5242,4692],{"class":673},[272,5244,5245],{"class":625},"(fresh)))\n",[272,5247,5248],{"class":274,"line":366},[272,5249,1373],{"class":625},[272,5251,5252],{"class":274,"line":372},[272,5253,300],{"emptyLinePlaceholder":234},[272,5255,5256,5258],{"class":274,"line":378},[272,5257,1946],{"class":621},[272,5259,5260],{"class":625}," data\n",[272,5262,5263],{"class":274,"line":384},[272,5264,1373],{"class":625},[272,5266,5267],{"class":274,"line":390},[272,5268,300],{"emptyLinePlaceholder":234},[272,5270,5271,5273,5276,5278,5280,5282],{"class":274,"line":396},[272,5272,1094],{"class":621},[272,5274,5275],{"class":654}," fresh",[272,5277,858],{"class":621},[272,5279,861],{"class":621},[272,5281,5058],{"class":673},[272,5283,1070],{"class":625},[272,5285,5286,5288,5290,5292,5294,5296,5298,5300],{"class":274,"line":401},[272,5287,861],{"class":621},[272,5289,4536],{"class":625},[272,5291,4676],{"class":673},[272,5293,5236],{"class":625},[272,5295,4687],{"class":654},[272,5297,582],{"class":625},[272,5299,4692],{"class":673},[272,5301,5302],{"class":625},"(fresh))\n",[272,5304,5305,5307],{"class":274,"line":407},[272,5306,1946],{"class":621},[272,5308,5309],{"class":625}," fresh\n",[272,5311,5312],{"class":274,"line":413},[272,5313,294],{"class":625},[15,5315,5317],{"id":5316},"cache-invalidation-patterns","Cache Invalidation Patterns",[20,5319,5320],{},"\"There are only two hard things in Computer Science: cache invalidation and naming things.\"",[20,5322,5323,5324,5327],{},"The hard part of cache invalidation is knowing which cached entries to invalidate when data changes. Simple cases are easy: update user 42, delete ",[95,5325,5326],{},"user:42",". Complex cases are not:",[264,5329,5331],{"className":606,"code":5330,"language":608,"meta":213,"style":213},"// When a post is published:\n// - Invalidate the post itself\n// - Invalidate the author's post count\n// - Invalidate the category listing\n// - Invalidate the homepage featured posts\n// - Invalidate search indexes\n",[95,5332,5333,5338,5343,5348,5353,5358],{"__ignoreMap":213},[272,5334,5335],{"class":274,"line":275},[272,5336,5337],{"class":615},"// When a post is published:\n",[272,5339,5340],{"class":274,"line":217},[272,5341,5342],{"class":615},"// - Invalidate the post itself\n",[272,5344,5345],{"class":274,"line":214},[272,5346,5347],{"class":615},"// - Invalidate the author's post count\n",[272,5349,5350],{"class":274,"line":291},[272,5351,5352],{"class":615},"// - Invalidate the category listing\n",[272,5354,5355],{"class":274,"line":297},[272,5356,5357],{"class":615},"// - Invalidate the homepage featured posts\n",[272,5359,5360],{"class":274,"line":303},[272,5361,5362],{"class":615},"// - Invalidate search indexes\n",[20,5364,5365],{},"For complex invalidation, use cache tags. Group related cache entries under a tag and invalidate the entire group:",[264,5367,5369],{"className":606,"code":5368,"language":608,"meta":213,"style":213},"async function cacheWithTags(\n key: string,\n tags: string[],\n value: unknown,\n ttl: number\n) {\n const pipeline = redis.pipeline()\n\n pipeline.setex(key, ttl, JSON.stringify(value))\n\n for (const tag of tags) {\n pipeline.sadd(`tag:${tag}`, key)\n pipeline.expire(`tag:${tag}`, ttl * 2)\n }\n\n await pipeline.exec()\n}\n\nAsync function invalidateTag(tag: string) {\n const keys = await redis.smembers(`tag:${tag}`)\n if (keys.length > 0) {\n await redis.del(...keys, `tag:${tag}`)\n }\n}\n\n// Usage\nawait cacheWithTags(\n `post:${postId}`,\n ['posts', `user:${userId}:posts`, 'featured'],\n postData,\n 3600\n)\n\n// When a post is updated, invalidate all related caches\nawait invalidateTag(`user:${userId}:posts`)\n",[95,5370,5371,5382,5392,5404,5415,5423,5427,5443,5447,5465,5469,5486,5506,5531,5535,5539,5550,5554,5558,5578,5604,5622,5646,5650,5654,5658,5663,5671,5683,5706,5711,5716,5720,5724,5729],{"__ignoreMap":213},[272,5372,5373,5375,5377,5380],{"class":274,"line":275},[272,5374,1216],{"class":621},[272,5376,1219],{"class":621},[272,5378,5379],{"class":673}," cacheWithTags",[272,5381,1225],{"class":625},[272,5383,5384,5386,5388,5390],{"class":274,"line":217},[272,5385,5047],{"class":666},[272,5387,670],{"class":621},[272,5389,1235],{"class":654},[272,5391,924],{"class":625},[272,5393,5394,5397,5399,5401],{"class":274,"line":214},[272,5395,5396],{"class":666}," tags",[272,5398,670],{"class":621},[272,5400,1235],{"class":654},[272,5402,5403],{"class":625},"[],\n",[272,5405,5406,5409,5411,5413],{"class":274,"line":291},[272,5407,5408],{"class":666}," value",[272,5410,670],{"class":621},[272,5412,655],{"class":654},[272,5414,924],{"class":625},[272,5416,5417,5419,5421],{"class":274,"line":297},[272,5418,5079],{"class":666},[272,5420,670],{"class":621},[272,5422,1258],{"class":654},[272,5424,5425],{"class":274,"line":303},[272,5426,803],{"class":625},[272,5428,5429,5431,5434,5436,5438,5441],{"class":274,"line":236},[272,5430,1094],{"class":621},[272,5432,5433],{"class":654}," pipeline",[272,5435,858],{"class":621},[272,5437,4536],{"class":625},[272,5439,5440],{"class":673},"pipeline",[272,5442,1070],{"class":625},[272,5444,5445],{"class":274,"line":314},[272,5446,300],{"emptyLinePlaceholder":234},[272,5448,5449,5452,5454,5456,5458,5460,5462],{"class":274,"line":320},[272,5450,5451],{"class":625}," pipeline.",[272,5453,4676],{"class":673},[272,5455,5236],{"class":625},[272,5457,4687],{"class":654},[272,5459,582],{"class":625},[272,5461,4692],{"class":673},[272,5463,5464],{"class":625},"(value))\n",[272,5466,5467],{"class":274,"line":325},[272,5468,300],{"emptyLinePlaceholder":234},[272,5470,5471,5474,5476,5478,5481,5483],{"class":274,"line":330},[272,5472,5473],{"class":621}," for",[272,5475,1078],{"class":625},[272,5477,696],{"class":621},[272,5479,5480],{"class":654}," tag",[272,5482,1086],{"class":621},[272,5484,5485],{"class":625}," tags) {\n",[272,5487,5488,5490,5493,5495,5498,5501,5503],{"class":274,"line":336},[272,5489,5451],{"class":625},[272,5491,5492],{"class":673},"sadd",[272,5494,1290],{"class":625},[272,5496,5497],{"class":632},"`tag:${",[272,5499,5500],{"class":625},"tag",[272,5502,4817],{"class":632},[272,5504,5505],{"class":625},", key)\n",[272,5507,5508,5510,5513,5515,5517,5519,5521,5524,5526,5529],{"class":274,"line":342},[272,5509,5451],{"class":625},[272,5511,5512],{"class":673},"expire",[272,5514,1290],{"class":625},[272,5516,5497],{"class":632},[272,5518,5500],{"class":625},[272,5520,4817],{"class":632},[272,5522,5523],{"class":625},", ttl ",[272,5525,4351],{"class":621},[272,5527,5528],{"class":654}," 2",[272,5530,1368],{"class":625},[272,5532,5533],{"class":274,"line":348},[272,5534,1373],{"class":625},[272,5536,5537],{"class":274,"line":354},[272,5538,300],{"emptyLinePlaceholder":234},[272,5540,5541,5543,5545,5548],{"class":274,"line":360},[272,5542,861],{"class":621},[272,5544,5451],{"class":625},[272,5546,5547],{"class":673},"exec",[272,5549,1070],{"class":625},[272,5551,5552],{"class":274,"line":366},[272,5553,294],{"class":625},[272,5555,5556],{"class":274,"line":372},[272,5557,300],{"emptyLinePlaceholder":234},[272,5559,5560,5563,5565,5568,5570,5572,5574,5576],{"class":274,"line":378},[272,5561,5562],{"class":625},"Async ",[272,5564,2697],{"class":621},[272,5566,5567],{"class":673}," invalidateTag",[272,5569,1290],{"class":625},[272,5571,5500],{"class":666},[272,5573,670],{"class":621},[272,5575,1235],{"class":654},[272,5577,803],{"class":625},[272,5579,5580,5582,5585,5587,5589,5591,5594,5596,5598,5600,5602],{"class":274,"line":384},[272,5581,1094],{"class":621},[272,5583,5584],{"class":654}," keys",[272,5586,858],{"class":621},[272,5588,861],{"class":621},[272,5590,4536],{"class":625},[272,5592,5593],{"class":673},"smembers",[272,5595,1290],{"class":625},[272,5597,5497],{"class":632},[272,5599,5500],{"class":625},[272,5601,4817],{"class":632},[272,5603,1368],{"class":625},[272,5605,5606,5608,5611,5614,5617,5620],{"class":274,"line":390},[272,5607,1342],{"class":621},[272,5609,5610],{"class":625}," (keys.",[272,5612,5613],{"class":654},"length",[272,5615,5616],{"class":621}," >",[272,5618,5619],{"class":654}," 0",[272,5621,803],{"class":625},[272,5623,5624,5626,5628,5630,5632,5635,5638,5640,5642,5644],{"class":274,"line":396},[272,5625,861],{"class":621},[272,5627,4536],{"class":625},[272,5629,4807],{"class":673},[272,5631,1290],{"class":625},[272,5633,5634],{"class":621},"...",[272,5636,5637],{"class":625},"keys, ",[272,5639,5497],{"class":632},[272,5641,5500],{"class":625},[272,5643,4817],{"class":632},[272,5645,1368],{"class":625},[272,5647,5648],{"class":274,"line":401},[272,5649,1373],{"class":625},[272,5651,5652],{"class":274,"line":407},[272,5653,294],{"class":625},[272,5655,5656],{"class":274,"line":413},[272,5657,300],{"emptyLinePlaceholder":234},[272,5659,5660],{"class":274,"line":418},[272,5661,5662],{"class":615},"// Usage\n",[272,5664,5665,5667,5669],{"class":274,"line":423},[272,5666,1510],{"class":621},[272,5668,5379],{"class":673},[272,5670,1225],{"class":625},[272,5672,5673,5676,5679,5681],{"class":274,"line":429},[272,5674,5675],{"class":632}," `post:${",[272,5677,5678],{"class":625},"postId",[272,5680,4817],{"class":632},[272,5682,924],{"class":625},[272,5684,5685,5687,5690,5692,5694,5696,5699,5701,5704],{"class":274,"line":434},[272,5686,746],{"class":625},[272,5688,5689],{"class":632},"'posts'",[272,5691,752],{"class":625},[272,5693,4812],{"class":632},[272,5695,4479],{"class":625},[272,5697,5698],{"class":632},"}:posts`",[272,5700,752],{"class":625},[272,5702,5703],{"class":632},"'featured'",[272,5705,775],{"class":625},[272,5707,5708],{"class":274,"line":440},[272,5709,5710],{"class":625}," postData,\n",[272,5712,5713],{"class":274,"line":446},[272,5714,5715],{"class":654}," 3600\n",[272,5717,5718],{"class":274,"line":452},[272,5719,1368],{"class":625},[272,5721,5722],{"class":274,"line":458},[272,5723,300],{"emptyLinePlaceholder":234},[272,5725,5726],{"class":274,"line":464},[272,5727,5728],{"class":615},"// When a post is updated, invalidate all related caches\n",[272,5730,5731,5733,5735,5737,5739,5741,5743],{"class":274,"line":470},[272,5732,1510],{"class":621},[272,5734,5567],{"class":673},[272,5736,1290],{"class":625},[272,5738,4812],{"class":632},[272,5740,4479],{"class":625},[272,5742,5698],{"class":632},[272,5744,1368],{"class":625},[15,5746,5748],{"id":5747},"session-storage","Session Storage",[20,5750,5751],{},"Redis is the standard choice for distributed session storage:",[264,5753,5755],{"className":606,"code":5754,"language":608,"meta":213,"style":213},"// With better-auth\nexport const auth = betterAuth({\n database: prismaAdapter(prisma, { provider: 'postgresql' }),\n session: {\n sessionStore: redisSessionStore({\n client: redis,\n prefix: 'session:',\n ttl: 60 * 60 * 24 * 7, // 7 days\n }),\n },\n})\n",[95,5756,5757,5762,5779,5796,5801,5811,5816,5826,5855,5859,5863],{"__ignoreMap":213},[272,5758,5759],{"class":274,"line":275},[272,5760,5761],{"class":615},"// With better-auth\n",[272,5763,5764,5767,5769,5772,5774,5777],{"class":274,"line":217},[272,5765,5766],{"class":621},"export",[272,5768,1094],{"class":621},[272,5770,5771],{"class":654}," auth",[272,5773,858],{"class":621},[272,5775,5776],{"class":673}," betterAuth",[272,5778,719],{"class":625},[272,5780,5781,5784,5787,5790,5793],{"class":274,"line":214},[272,5782,5783],{"class":625}," database: ",[272,5785,5786],{"class":673},"prismaAdapter",[272,5788,5789],{"class":625},"(prisma, { provider: ",[272,5791,5792],{"class":632},"'postgresql'",[272,5794,5795],{"class":625}," }),\n",[272,5797,5798],{"class":274,"line":291},[272,5799,5800],{"class":625}," session: {\n",[272,5802,5803,5806,5809],{"class":274,"line":297},[272,5804,5805],{"class":625}," sessionStore: ",[272,5807,5808],{"class":673},"redisSessionStore",[272,5810,719],{"class":625},[272,5812,5813],{"class":274,"line":303},[272,5814,5815],{"class":625}," client: redis,\n",[272,5817,5818,5821,5824],{"class":274,"line":236},[272,5819,5820],{"class":625}," prefix: ",[272,5822,5823],{"class":632},"'session:'",[272,5825,924],{"class":625},[272,5827,5828,5831,5834,5837,5840,5842,5845,5847,5850,5852],{"class":274,"line":314},[272,5829,5830],{"class":625}," ttl: ",[272,5832,5833],{"class":654},"60",[272,5835,5836],{"class":621}," *",[272,5838,5839],{"class":654}," 60",[272,5841,5836],{"class":621},[272,5843,5844],{"class":654}," 24",[272,5846,5836],{"class":621},[272,5848,5849],{"class":654}," 7",[272,5851,752],{"class":625},[272,5853,5854],{"class":615},"// 7 days\n",[272,5856,5857],{"class":274,"line":320},[272,5858,5795],{"class":625},[272,5860,5861],{"class":274,"line":325},[272,5862,879],{"class":625},[272,5864,5865],{"class":274,"line":330},[272,5866,884],{"class":625},[20,5868,5869],{},"Sessions in Redis rather than PostgreSQL means:",[185,5871,5872,5875,5878],{},[188,5873,5874],{},"Session reads are sub-millisecond vs 5-10ms for database reads",[188,5876,5877],{},"Session storage scales independently of your main database",[188,5879,5880],{},"Session invalidation (logout) is instant",[15,5882,5884],{"id":5883},"rate-limiting-with-redis","Rate Limiting With Redis",[20,5886,5887],{},"Redis's atomic increment operations are perfect for rate limiting:",[264,5889,5891],{"className":606,"code":5890,"language":608,"meta":213,"style":213},"async function rateLimit(\n identifier: string,\n limit: number,\n windowSeconds: number\n): Promise\u003C{ allowed: boolean; remaining: number; resetAt: number }> {\n const key = `rate:${identifier}:${Math.floor(Date.now() / (windowSeconds * 1000))}`\n\n const current = await redis.incr(key)\n\n if (current === 1) {\n await redis.expire(key, windowSeconds)\n }\n\n const ttl = await redis.ttl(key)\n const resetAt = Date.now() + ttl * 1000\n\n return {\n allowed: current \u003C= limit,\n remaining: Math.max(0, limit - current),\n resetAt,\n }\n}\n",[95,5892,5893,5904,5915,5927,5936,5976,6029,6033,6052,6056,6070,6081,6085,6089,6105,6130,6134,6140,6151,6172,6177,6181],{"__ignoreMap":213},[272,5894,5895,5897,5899,5902],{"class":274,"line":275},[272,5896,1216],{"class":621},[272,5898,1219],{"class":621},[272,5900,5901],{"class":673}," rateLimit",[272,5903,1225],{"class":625},[272,5905,5906,5909,5911,5913],{"class":274,"line":217},[272,5907,5908],{"class":666}," identifier",[272,5910,670],{"class":621},[272,5912,1235],{"class":654},[272,5914,924],{"class":625},[272,5916,5917,5920,5922,5925],{"class":274,"line":214},[272,5918,5919],{"class":666}," limit",[272,5921,670],{"class":621},[272,5923,5924],{"class":654}," number",[272,5926,924],{"class":625},[272,5928,5929,5932,5934],{"class":274,"line":291},[272,5930,5931],{"class":666}," windowSeconds",[272,5933,670],{"class":621},[272,5935,1258],{"class":654},[272,5937,5938,5940,5942,5944,5947,5950,5952,5955,5957,5960,5962,5964,5966,5969,5971,5973],{"class":274,"line":297},[272,5939,1263],{"class":625},[272,5941,670],{"class":621},[272,5943,1268],{"class":673},[272,5945,5946],{"class":625},"\u003C{ ",[272,5948,5949],{"class":666},"allowed",[272,5951,670],{"class":621},[272,5953,5954],{"class":654}," boolean",[272,5956,2683],{"class":625},[272,5958,5959],{"class":666},"remaining",[272,5961,670],{"class":621},[272,5963,5924],{"class":654},[272,5965,2683],{"class":625},[272,5967,5968],{"class":666},"resetAt",[272,5970,670],{"class":621},[272,5972,5924],{"class":654},[272,5974,5975],{"class":625}," }> {\n",[272,5977,5978,5980,5982,5984,5987,5990,5993,5996,5998,6001,6003,6006,6008,6010,6012,6014,6016,6019,6021,6024,6027],{"class":274,"line":303},[272,5979,1094],{"class":621},[272,5981,5047],{"class":654},[272,5983,858],{"class":621},[272,5985,5986],{"class":632}," `rate:${",[272,5988,5989],{"class":625},"identifier",[272,5991,5992],{"class":632},"}:${",[272,5994,5995],{"class":625},"Math",[272,5997,582],{"class":632},[272,5999,6000],{"class":673},"floor",[272,6002,1290],{"class":632},[272,6004,6005],{"class":625},"Date",[272,6007,582],{"class":632},[272,6009,1823],{"class":673},[272,6011,2808],{"class":632},[272,6013,2816],{"class":621},[272,6015,1078],{"class":632},[272,6017,6018],{"class":625},"windowSeconds",[272,6020,5836],{"class":621},[272,6022,6023],{"class":654}," 1000",[272,6025,6026],{"class":632},"))",[272,6028,4513],{"class":632},[272,6030,6031],{"class":274,"line":236},[272,6032,300],{"emptyLinePlaceholder":234},[272,6034,6035,6037,6040,6042,6044,6046,6049],{"class":274,"line":314},[272,6036,1094],{"class":621},[272,6038,6039],{"class":654}," current",[272,6041,858],{"class":621},[272,6043,861],{"class":621},[272,6045,4536],{"class":625},[272,6047,6048],{"class":673},"incr",[272,6050,6051],{"class":625},"(key)\n",[272,6053,6054],{"class":274,"line":320},[272,6055,300],{"emptyLinePlaceholder":234},[272,6057,6058,6060,6063,6065,6068],{"class":274,"line":325},[272,6059,1342],{"class":621},[272,6061,6062],{"class":625}," (current ",[272,6064,1997],{"class":621},[272,6066,6067],{"class":654}," 1",[272,6069,803],{"class":625},[272,6071,6072,6074,6076,6078],{"class":274,"line":330},[272,6073,861],{"class":621},[272,6075,4536],{"class":625},[272,6077,5512],{"class":673},[272,6079,6080],{"class":625},"(key, windowSeconds)\n",[272,6082,6083],{"class":274,"line":336},[272,6084,1373],{"class":625},[272,6086,6087],{"class":274,"line":342},[272,6088,300],{"emptyLinePlaceholder":234},[272,6090,6091,6093,6095,6097,6099,6101,6103],{"class":274,"line":348},[272,6092,1094],{"class":621},[272,6094,5079],{"class":654},[272,6096,858],{"class":621},[272,6098,861],{"class":621},[272,6100,4536],{"class":625},[272,6102,5144],{"class":673},[272,6104,6051],{"class":625},[272,6106,6107,6109,6112,6114,6116,6118,6120,6123,6125,6127],{"class":274,"line":354},[272,6108,1094],{"class":621},[272,6110,6111],{"class":654}," resetAt",[272,6113,858],{"class":621},[272,6115,1820],{"class":625},[272,6117,1823],{"class":673},[272,6119,2808],{"class":625},[272,6121,6122],{"class":621},"+",[272,6124,5204],{"class":625},[272,6126,4351],{"class":621},[272,6128,6129],{"class":654}," 1000\n",[272,6131,6132],{"class":274,"line":360},[272,6133,300],{"emptyLinePlaceholder":234},[272,6135,6136,6138],{"class":274,"line":366},[272,6137,1946],{"class":621},[272,6139,661],{"class":625},[272,6141,6142,6145,6148],{"class":274,"line":372},[272,6143,6144],{"class":625}," allowed: current ",[272,6146,6147],{"class":621},"\u003C=",[272,6149,6150],{"class":625}," limit,\n",[272,6152,6153,6156,6159,6161,6164,6167,6169],{"class":274,"line":378},[272,6154,6155],{"class":625}," remaining: Math.",[272,6157,6158],{"class":673},"max",[272,6160,1290],{"class":625},[272,6162,6163],{"class":654},"0",[272,6165,6166],{"class":625},", limit ",[272,6168,1871],{"class":621},[272,6170,6171],{"class":625}," current),\n",[272,6173,6174],{"class":274,"line":384},[272,6175,6176],{"class":625}," resetAt,\n",[272,6178,6179],{"class":274,"line":390},[272,6180,1373],{"class":625},[272,6182,6183],{"class":274,"line":396},[272,6184,294],{"class":625},[20,6186,6187],{},"The sliding window counter pattern is more accurate but more complex. For most rate limiting use cases, this fixed window approach is sufficient.",[15,6189,6191],{"id":6190},"avoiding-common-mistakes","Avoiding Common Mistakes",[20,6193,6194,6197],{},[57,6195,6196],{},"Never cache sensitive data without encryption."," Redis is a key-value store, not a secure vault. Cache tokens, session IDs (not session data with PII), and computed values. If you must cache sensitive data, encrypt it.",[20,6199,6200,6203],{},[57,6201,6202],{},"Handle Redis errors gracefully."," Redis unavailability should degrade your application, not crash it:",[264,6205,6207],{"className":606,"code":6206,"language":608,"meta":213,"style":213},"async function getWithFallback\u003CT>(key: string, fallback: () => Promise\u003CT>): Promise\u003CT> {\n try {\n const cached = await redis.get(key)\n if (cached) return JSON.parse(cached)\n } catch (err) {\n console.error('Redis error, falling back to database:', err)\n }\n\n return fallback()\n}\n",[95,6208,6209,6262,6269,6285,6304,6315,6328,6332,6336,6345],{"__ignoreMap":213},[272,6210,6211,6213,6215,6218,6220,6222,6225,6228,6230,6232,6234,6237,6239,6241,6243,6245,6247,6249,6252,6254,6256,6258,6260],{"class":274,"line":275},[272,6212,1216],{"class":621},[272,6214,1219],{"class":621},[272,6216,6217],{"class":673}," getWithFallback",[272,6219,1271],{"class":625},[272,6221,5039],{"class":673},[272,6223,6224],{"class":625},">(",[272,6226,6227],{"class":666},"key",[272,6229,670],{"class":621},[272,6231,1235],{"class":654},[272,6233,752],{"class":625},[272,6235,6236],{"class":673},"fallback",[272,6238,670],{"class":621},[272,6240,5063],{"class":625},[272,6242,1303],{"class":621},[272,6244,1268],{"class":673},[272,6246,1271],{"class":625},[272,6248,5039],{"class":673},[272,6250,6251],{"class":625},">)",[272,6253,670],{"class":621},[272,6255,1268],{"class":673},[272,6257,1271],{"class":625},[272,6259,5039],{"class":673},[272,6261,1277],{"class":625},[272,6263,6264,6267],{"class":274,"line":217},[272,6265,6266],{"class":621}," try",[272,6268,661],{"class":625},[272,6270,6271,6273,6275,6277,6279,6281,6283],{"class":274,"line":214},[272,6272,1094],{"class":621},[272,6274,4529],{"class":654},[272,6276,858],{"class":621},[272,6278,861],{"class":621},[272,6280,4536],{"class":625},[272,6282,4539],{"class":673},[272,6284,6051],{"class":625},[272,6286,6287,6289,6292,6295,6297,6299,6301],{"class":274,"line":291},[272,6288,1342],{"class":621},[272,6290,6291],{"class":625}," (cached) ",[272,6293,6294],{"class":621},"return",[272,6296,4556],{"class":654},[272,6298,582],{"class":625},[272,6300,4561],{"class":673},[272,6302,6303],{"class":625},"(cached)\n",[272,6305,6306,6309,6312],{"class":274,"line":297},[272,6307,6308],{"class":625}," } ",[272,6310,6311],{"class":621},"catch",[272,6313,6314],{"class":625}," (err) {\n",[272,6316,6317,6319,6321,6323,6326],{"class":274,"line":303},[272,6318,1887],{"class":625},[272,6320,4420],{"class":673},[272,6322,1290],{"class":625},[272,6324,6325],{"class":632},"'Redis error, falling back to database:'",[272,6327,4428],{"class":625},[272,6329,6330],{"class":274,"line":236},[272,6331,1373],{"class":625},[272,6333,6334],{"class":274,"line":314},[272,6335,300],{"emptyLinePlaceholder":234},[272,6337,6338,6340,6343],{"class":274,"line":320},[272,6339,1946],{"class":621},[272,6341,6342],{"class":673}," fallback",[272,6344,1070],{"class":625},[272,6346,6347],{"class":274,"line":325},[272,6348,294],{"class":625},[20,6350,6351,6354],{},[57,6352,6353],{},"Use pipelines for multiple operations."," Multiple Redis commands in a single round trip:",[264,6356,6358],{"className":606,"code":6357,"language":608,"meta":213,"style":213},"const pipeline = redis.pipeline()\npipeline.get('key1')\npipeline.get('key2')\npipeline.incr('counter')\nconst results = await pipeline.exec()\n",[95,6359,6360,6374,6388,6401,6414],{"__ignoreMap":213},[272,6361,6362,6364,6366,6368,6370,6372],{"class":274,"line":275},[272,6363,696],{"class":621},[272,6365,5433],{"class":654},[272,6367,858],{"class":621},[272,6369,4536],{"class":625},[272,6371,5440],{"class":673},[272,6373,1070],{"class":625},[272,6375,6376,6379,6381,6383,6386],{"class":274,"line":217},[272,6377,6378],{"class":625},"pipeline.",[272,6380,4539],{"class":673},[272,6382,1290],{"class":625},[272,6384,6385],{"class":632},"'key1'",[272,6387,1368],{"class":625},[272,6389,6390,6392,6394,6396,6399],{"class":274,"line":214},[272,6391,6378],{"class":625},[272,6393,4539],{"class":673},[272,6395,1290],{"class":625},[272,6397,6398],{"class":632},"'key2'",[272,6400,1368],{"class":625},[272,6402,6403,6405,6407,6409,6412],{"class":274,"line":291},[272,6404,6378],{"class":625},[272,6406,6048],{"class":673},[272,6408,1290],{"class":625},[272,6410,6411],{"class":632},"'counter'",[272,6413,1368],{"class":625},[272,6415,6416,6418,6420,6422,6424,6426,6428],{"class":274,"line":297},[272,6417,696],{"class":621},[272,6419,1679],{"class":654},[272,6421,858],{"class":621},[272,6423,861],{"class":621},[272,6425,5451],{"class":625},[272,6427,5547],{"class":673},[272,6429,1070],{"class":625},[20,6431,6432],{},"Caching is powerful when applied deliberately. Know what you are caching, why, for how long, and how you will handle stale data. The complexity is worth it when you have the measurements to justify it.",[33,6434],{},[20,6436,6437,6438,582],{},"Designing a caching strategy for a high-traffic application or dealing with Redis configuration issues? Book a call and let's work through it: ",[171,6439,176],{"href":173,"rel":6440},[175],[33,6442],{},[15,6444,183],{"id":182},[185,6446,6447,6453,6457,6463],{},[188,6448,6449],{},[171,6450,6452],{"href":6451},"/blog/web-caching-strategies","Web Caching Strategies: HTTP Cache, CDN, and Application Cache",[188,6454,6455],{},[171,6456,2127],{"href":2126},[188,6458,6459],{},[171,6460,6462],{"href":6461},"/blog/database-backup-strategies","Database Backup Strategies for Production: The Ones That Actually Work",[188,6464,6465],{},[171,6466,6468],{"href":6467},"/blog/database-migrations-guide","Database Migrations in Production: Zero-Downtime Strategies",[2141,6470,6471],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":213,"searchDepth":214,"depth":214,"links":6473},[6474,6475,6476,6477,6478,6479,6480,6481,6482,6483],{"id":4173,"depth":217,"text":4174},{"id":4208,"depth":217,"text":4209},{"id":4456,"depth":217,"text":4457},{"id":4839,"depth":217,"text":4840},{"id":4972,"depth":217,"text":4973},{"id":5316,"depth":217,"text":5317},{"id":5747,"depth":217,"text":5748},{"id":5883,"depth":217,"text":5884},{"id":6190,"depth":217,"text":6191},{"id":182,"depth":217,"text":183},"A practical guide to Redis caching — cache-aside vs write-through, TTL strategy, cache invalidation, session storage, and the common mistakes that make caches unreliable.",[6486,6487],"Redis caching","caching strategies",{},"/blog/redis-caching-guide",{"title":4161,"description":6484},"blog/redis-caching-guide",[6493,6494,6495],"Redis","Caching","Backend","2CHnqIw09PR7HpwuFLQnWBbmEPO_letvqJ_3DSHuGqY",{"id":6498,"title":6499,"author":6500,"body":6501,"category":6801,"date":225,"description":6802,"extension":227,"featured":228,"image":229,"keywords":6803,"meta":6808,"navigation":234,"path":6809,"readTime":325,"seo":6810,"stem":6811,"tags":6812,"__hash__":6816},"blog/blog/refactoring-legacy-systems.md","Refactoring Legacy Systems: A Field Guide",{"name":9,"bio":10},{"type":12,"value":6502,"toc":6786},[6503,6507,6510,6513,6516,6518,6522,6525,6528,6531,6534,6537,6539,6543,6546,6549,6553,6591,6594,6596,6600,6607,6610,6624,6627,6630,6632,6636,6639,6643,6646,6672,6675,6679,6682,6685,6688,6690,6694,6697,6703,6709,6715,6721,6727,6729,6733,6736,6739,6742,6744,6747,6749,6756,6758,6760],[15,6504,6506],{"id":6505},"nobody-wants-to-work-on-legacy-systems-nobody-can-avoid-it","Nobody Wants to Work on Legacy Systems. Nobody Can Avoid It.",[20,6508,6509],{},"If you've been in software long enough, you've inherited a legacy system. Maybe it's a ten-year-old monolith that processes millions of dollars in transactions daily. Maybe it's a codebase with no tests, no documentation, and one engineer who \"sort of remembers\" how the core module works. Maybe it's something that runs on an unsupported framework version because upgrading would break things nobody fully understands.",[20,6511,6512],{},"\"Legacy\" is a spectrum, but the common thread is: the system has value, it has risk, and it can't be safely changed without a strategy.",[20,6514,6515],{},"Here's the strategy.",[33,6517],{},[15,6519,6521],{"id":6520},"the-fundamental-rule-never-rewrite-from-scratch","The Fundamental Rule: Never Rewrite From Scratch",[20,6523,6524],{},"Before any tactical advice, this principle deserves its own section because violating it is the most expensive mistake teams make with legacy systems.",[20,6526,6527],{},"The \"big bang rewrite\" — stopping feature development, assembling a team, and building the replacement from the ground up — almost never succeeds. Joel Spolsky wrote about this in 2000. It still happens constantly.",[20,6529,6530],{},"Why it fails: the original system, however ugly, encodes an enormous amount of business logic, edge cases, and institutional knowledge. Some of it is documented. Most of it is in the code. When you start fresh, you don't know what you don't know. You'll spend months building what you thought the system did, and then discover that the original system had twenty-three special cases for specific customer types, three different rounding behaviors for financial calculations depending on jurisdiction, and a quirky authentication flow that two enterprise clients depend on.",[20,6532,6533],{},"The rewrite team builds a cleaner system. The cleaner system doesn't match the original behavior in the ways that actually matter. Customers notice. Leadership notices. The project gets cancelled or the team spends another six months retrofitting the \"easy\" replacement with the complexity they were trying to escape.",[20,6535,6536],{},"The safe alternative is incremental migration: extract value from the existing system while gradually replacing it, never stopping delivery.",[33,6538],{},[15,6540,6542],{"id":6541},"the-strangler-fig-pattern","The Strangler Fig Pattern",[20,6544,6545],{},"The Strangler Fig is the foundational strategy for safe legacy migration, named for a vine that wraps around a host tree and gradually replaces it.",[20,6547,6548],{},"The pattern: build new functionality beside the legacy system, intercept incoming requests at a routing layer, and direct traffic to the new system for the parts you've migrated. Over time, the new system handles more and more requests, the legacy system handles fewer, until eventually the old system is no longer needed and can be decommissioned.",[3151,6550,6552],{"id":6551},"implementation","Implementation",[1642,6554,6555,6561,6567,6573,6579,6585],{},[188,6556,6557,6560],{},[57,6558,6559],{},"Create a facade."," Put a routing layer — a reverse proxy, an API gateway, or application-level routing — in front of the legacy system. Initially, all traffic passes through to the legacy system. This is your control point.",[188,6562,6563,6566],{},[57,6564,6565],{},"Identify extraction candidates."," Find functionality that can be moved without requiring changes to everything else. Good candidates: features with clear, well-defined inputs and outputs, low coupling to the rest of the system, or areas that need to change frequently.",[188,6568,6569,6572],{},[57,6570,6571],{},"Build the replacement in parallel."," Implement the extracted functionality in the new system. Keep the legacy system running unchanged.",[188,6574,6575,6578],{},[57,6576,6577],{},"Test in production with real traffic."," Run the legacy and new implementations in parallel (shadow mode) or use feature flags to route a percentage of traffic to the new implementation. Compare results.",[188,6580,6581,6584],{},[57,6582,6583],{},"Shift traffic."," Once confident the new implementation matches the legacy behavior (including edge cases), shift traffic. Roll out gradually — 5%, 25%, 50%, 100% — with rollback capability at each stage.",[188,6586,6587,6590],{},[57,6588,6589],{},"Delete the legacy code."," The most satisfying step. Only do this after the new path has been stable in production for a meaningful period.",[20,6592,6593],{},"Repeat for the next component. Over months or years, the legacy system shrinks and the new system grows until nothing remains to strangle.",[33,6595],{},[15,6597,6599],{"id":6598},"characterization-testing-understanding-what-youre-replacing","Characterization Testing: Understanding What You're Replacing",[20,6601,6602,6603,6606],{},"Before you can safely refactor or replace a component, you need to understand what it does — including the behavior you didn't design intentionally. ",[57,6604,6605],{},"Characterization tests"," document the actual behavior of existing code, whether or not that behavior was intended.",[20,6608,6609],{},"The process:",[1642,6611,6612,6615,6618,6621],{},[188,6613,6614],{},"Write tests that call the legacy code with various inputs and capture the actual outputs",[188,6616,6617],{},"Use these outputs as expected values — you're testing \"this is what it does\" not \"this is what it should do\"",[188,6619,6620],{},"Use coverage tools to ensure you've exercised the code paths that matter",[188,6622,6623],{},"Run these tests before and after any change to detect behavioral regressions",[20,6625,6626],{},"Characterization tests aren't the same as unit tests. You're not asserting what the code should do — you're documenting what it does. When you migrate functionality, these tests become your acceptance criteria: the new implementation must match the old implementation's behavior for all tested inputs.",[20,6628,6629],{},"This approach lets you refactor with confidence even when you don't fully understand why the code works the way it does.",[33,6631],{},[15,6633,6635],{"id":6634},"database-migration-the-hard-part","Database Migration: The Hard Part",[20,6637,6638],{},"For most legacy systems, the database is the most dangerous part of the migration. Business logic frequently lives in stored procedures and triggers. Schema changes affect multiple consumers. Data quality issues that have accumulated over years surface during migration.",[3151,6640,6642],{"id":6641},"the-expand-contract-pattern","The Expand-Contract Pattern",[20,6644,6645],{},"For schema migrations without downtime:",[1642,6647,6648,6654,6660,6666],{},[188,6649,6650,6653],{},[57,6651,6652],{},"Expand:"," Add the new structure alongside the old (new column, new table, new relationship) without removing anything.",[188,6655,6656,6659],{},[57,6657,6658],{},"Migrate:"," Write logic to populate the new structure from the old, and keep it in sync during the transition period.",[188,6661,6662,6665],{},[57,6663,6664],{},"Switch:"," Update the application to read from and write to the new structure.",[188,6667,6668,6671],{},[57,6669,6670],{},"Contract:"," Once the old structure is no longer being used, remove it.",[20,6673,6674],{},"This pattern ensures that at every point in the process, the application works with the database as it exists. There's no moment where a half-migrated schema breaks the running system.",[3151,6676,6678],{"id":6677},"dealing-with-shared-databases","Dealing With Shared Databases",[20,6680,6681],{},"Legacy systems often share a database across multiple applications or processes. This is the hardest migration scenario because you can't own the migration — every consumer of the shared database is a stakeholder.",[20,6683,6684],{},"The first step is isolation: understand every consumer of every table and column. This is often more difficult than it should be because the dependencies weren't documented. Use database query logging to surface actual usage patterns.",[20,6686,6687],{},"From there, the path is usually: extract the new service with its own database, expose a migration API, and update consumers one at a time.",[33,6689],{},[15,6691,6693],{"id":6692},"risk-management-during-migration","Risk Management During Migration",[20,6695,6696],{},"Legacy migrations carry risk because the system is in production and the business depends on it. Risk management isn't optional.",[20,6698,6699,6702],{},[57,6700,6701],{},"Feature flags everywhere."," Use feature flags to control which implementation path is active. This lets you roll back at the application level without a deployment.",[20,6704,6705,6708],{},[57,6706,6707],{},"Dark launching."," Run the new implementation in parallel with the legacy system, compare results, but only use the legacy result for the actual response. Find discrepancies before they affect customers.",[20,6710,6711,6714],{},[57,6712,6713],{},"Incremental rollouts."," Never flip 100% of traffic to a new implementation on day one. Use canary deployments or percentage rollouts with automatic rollback triggers.",[20,6716,6717,6720],{},[57,6718,6719],{},"Define success criteria in advance."," What does success look like? Error rate below X, latency under Y ms, no data discrepancies in Z% of transactions. Have the criteria before you start the migration, not after.",[20,6722,6723,6726],{},[57,6724,6725],{},"Know your rollback path."," For every migration step, know exactly how to revert. Test the rollback path before you need it.",[33,6728],{},[15,6730,6732],{"id":6731},"the-organizational-dimension","The Organizational Dimension",[20,6734,6735],{},"Legacy migrations are not just technical projects — they're organizational ones. A multi-year migration requires sustained organizational commitment in the face of constant pressure to ship new features instead.",[20,6737,6738],{},"Make the progress visible. Track what percentage of traffic goes through the new system. Celebrate milestones when components are decommissioned. Show the business the velocity gains that come as the legacy system shrinks.",[20,6740,6741],{},"And maintain the discipline not to add new features to the legacy system. The strangler fig only works if the legacy system actually shrinks. Every time you add a feature to the old system to avoid the migration cost, you're extending the timeline.",[33,6743],{},[20,6745,6746],{},"Legacy system work is unglamorous and undervalued in most organizations. It's also some of the most technically demanding and highest-impact work in software. Systems that process billions of dollars in transactions don't get replaced in a single sprint. They get replaced carefully, incrementally, with enormous attention to the details that business continuity demands.",[33,6748],{},[20,6750,6751,6752],{},"If you're facing a legacy migration and want to think through the strategy, ",[171,6753,6755],{"href":173,"rel":6754},[175],"let's have a direct conversation.",[33,6757],{},[15,6759,183],{"id":182},[185,6761,6762,6768,6774,6780],{},[188,6763,6764],{},[171,6765,6767],{"href":6766},"/blog/technical-debt-management","Managing Technical Debt Before It Manages You",[188,6769,6770],{},[171,6771,6773],{"href":6772},"/blog/distributed-systems-fundamentals","Distributed Systems Fundamentals Every Developer Should Know",[188,6775,6776],{},[171,6777,6779],{"href":6778},"/blog/api-design-best-practices","API Design Best Practices That Survive Production",[188,6781,6782],{},[171,6783,6785],{"href":6784},"/blog/api-gateway-patterns","API Gateway Patterns: More Than Just a Reverse Proxy",{"title":213,"searchDepth":214,"depth":214,"links":6787},[6788,6789,6790,6793,6794,6798,6799,6800],{"id":6505,"depth":217,"text":6506},{"id":6520,"depth":217,"text":6521},{"id":6541,"depth":217,"text":6542,"children":6791},[6792],{"id":6551,"depth":214,"text":6552},{"id":6598,"depth":217,"text":6599},{"id":6634,"depth":217,"text":6635,"children":6795},[6796,6797],{"id":6641,"depth":214,"text":6642},{"id":6677,"depth":214,"text":6678},{"id":6692,"depth":217,"text":6693},{"id":6731,"depth":217,"text":6732},{"id":182,"depth":217,"text":183},"Architecture","Refactoring legacy systems requires more than technical skill — it requires a strategy that manages risk while maintaining delivery. Here's the field guide I wish I had before my first major migration.",[6804,6805,6806,6807],"refactoring legacy systems","legacy system migration","strangler fig pattern","legacy software modernization",{},"/blog/refactoring-legacy-systems",{"title":6499,"description":6802},"blog/refactoring-legacy-systems",[6813,6814,2574,6815],"Refactoring","Legacy Systems","Technical Debt","3OGt_e5PMP7BAgo9L975_Bcx7cY5xV_ASMdoHqihxfg",{"id":6818,"title":6819,"author":6820,"body":6821,"category":224,"date":225,"description":7050,"extension":227,"featured":228,"image":229,"keywords":7051,"meta":7054,"navigation":234,"path":7055,"readTime":236,"seo":7056,"stem":7057,"tags":7058,"__hash__":7061},"blog/blog/remote-software-development.md","Remote Software Development: How Distributed Teams Can Build Better Products",{"name":9,"bio":10},{"type":12,"value":6822,"toc":7040},[6823,6827,6830,6833,6835,6839,6842,6845,6848,6850,6854,6857,6860,6866,6872,6878,6880,6884,6887,6890,6907,6910,6924,6927,6929,6933,6936,6939,6945,6951,6957,6963,6965,6969,6972,6978,6984,6990,6992,6996,6999,7002,7005,7007,7010,7016,7018,7020],[15,6824,6826],{"id":6825},"remote-teams-arent-the-problem","Remote Teams Aren't the Problem",[20,6828,6829],{},"When a remote software team underperforms, people blame the format. \"Remote just doesn't work for engineering.\" This is almost always wrong. Remote doesn't fail because people are at home — it fails because the team didn't build the coordination systems that remote work requires. Co-located teams get away with bad communication habits because they can tap someone on the shoulder. Remote teams can't.",[20,6831,6832],{},"The good news is that the discipline required to run a distributed team well — clear written communication, explicit documentation, structured processes — also produces better outcomes in co-located teams. Forcing yourself to run remote right makes you better at running teams in general.",[33,6834],{},[15,6836,6838],{"id":6837},"the-coordination-tax-is-real-but-manageable","The Coordination Tax Is Real, But Manageable",[20,6840,6841],{},"Remote teams have a coordination overhead that co-located teams don't. Answering a question takes longer. Spontaneous collaboration takes deliberate effort. Misunderstandings that would be caught in a whiteboard session can accumulate silently for days.",[20,6843,6844],{},"The goal isn't to eliminate the coordination tax — it's to pay it efficiently. Most of the operational pain in distributed teams comes from trying to run remote teams the same way you'd run co-located teams, just with video calls substituted for meetings. That doesn't work.",[20,6846,6847],{},"What works is designing for async by default and treating synchronous time as a limited, high-value resource.",[33,6849],{},[15,6851,6853],{"id":6852},"async-by-default","Async by Default",[20,6855,6856],{},"The default assumption for a distributed team should be that any given teammate is not immediately available. They're in a different time zone, they're in a focus block, they're handling a personal situation. If your team's work depends on immediate responses to questions, you'll be constantly blocked.",[20,6858,6859],{},"Design your workflows to reduce real-time dependencies:",[20,6861,6862,6865],{},[57,6863,6864],{},"Write decisions down before making them."," If every architectural decision happens in a call and the output is verbal agreement, you've created a system where nobody can move forward without being on the call, and the institutional memory lives nowhere. Write proposals before meetings, circulate them asynchronously, and use the meeting for the decision — not the information transfer.",[20,6867,6868,6871],{},[57,6869,6870],{},"Use threaded communication tools and use threads correctly."," Slack's thread feature exists for a reason. A message that generates 40 replies in the main channel is noise for everyone. A threaded conversation on a specific topic is searchable, contextual, and doesn't interrupt people who don't need to be involved.",[20,6873,6874,6877],{},[57,6875,6876],{},"Set explicit response time expectations."," Some messages need a same-day response. Some need a response within 24 hours. Some are FYI and require no response. Make the expectation explicit rather than leaving people to guess. \"No response needed, just keeping you informed\" and \"need your input by Thursday EOD\" are both valid and helpful.",[33,6879],{},[15,6881,6883],{"id":6882},"synchronous-time-as-a-scarce-resource","Synchronous Time as a Scarce Resource",[20,6885,6886],{},"When the team treats every question as a meeting and every update as a call, you end up with a calendar full of meetings and no time to write code. Synchronous time should be rationed and used for things that genuinely benefit from real-time interaction.",[20,6888,6889],{},"The meetings that are worth it:",[185,6891,6892,6895,6898,6901,6904],{},[188,6893,6894],{},"Sprint planning (decisions with multiple stakeholders, needs debate and consensus)",[188,6896,6897],{},"System design discussions (visual, iterative, benefits from fast back-and-forth)",[188,6899,6900],{},"Code review of complex or sensitive changes (nuance is easier in conversation)",[188,6902,6903],{},"Team retrospectives (psychological safety benefits from seeing faces)",[188,6905,6906],{},"1:1s (relationship-building and career conversations)",[20,6908,6909],{},"The meetings that usually aren't:",[185,6911,6912,6915,6918,6921],{},[188,6913,6914],{},"Status updates (better as async written updates)",[188,6916,6917],{},"Questions that could be answered in a thread",[188,6919,6920],{},"Demos that could be recorded and watched at 1.5x speed",[188,6922,6923],{},"Decisions that one person has the authority to make unilaterally",[20,6925,6926],{},"When you cut meetings that shouldn't be meetings, the meetings that remain become more valuable because attendees come prepared and treat the time seriously.",[33,6928],{},[15,6930,6932],{"id":6931},"documentation-as-infrastructure","Documentation as Infrastructure",[20,6934,6935],{},"Distributed teams live or die by their documentation. This isn't documentation as a nice-to-have — it's documentation as the operating system of the team.",[20,6937,6938],{},"What needs to be written down:",[20,6940,6941,6944],{},[57,6942,6943],{},"Architecture and technical decisions."," When a decision is made — why this database, why this service boundary, why this authentication approach — write it down with the reasoning. When someone joins the team in six months, they shouldn't have to reverse-engineer the architectural intent from the code.",[20,6946,6947,6950],{},[57,6948,6949],{},"Development environment setup."," The README should work. Every developer on the team should be able to go from nothing to a running local environment by following the README without asking anyone for help. Test this assumption every time someone new joins. Fix what breaks.",[20,6952,6953,6956],{},[57,6954,6955],{},"The on-call runbook."," What do you do when the database is slow? When the job queue backs up? When an error rate spikes? Document the symptoms, the diagnostic steps, and the resolution for every incident that has happened more than once. The second person who encounters it shouldn't need to work it out from scratch.",[20,6958,6959,6962],{},[57,6960,6961],{},"Decision log."," A living document of significant technical and product decisions, when they were made, what the alternatives were, and why the chosen direction was selected. This is the most underused and most valuable document type in most engineering teams.",[33,6964],{},[15,6966,6968],{"id":6967},"hiring-for-remote-effectiveness","Hiring for Remote Effectiveness",[20,6970,6971],{},"Not every developer is equally effective in a remote environment. The skills that make someone excellent in a co-located team don't automatically transfer. When hiring for a distributed team, these signals matter:",[20,6973,6974,6977],{},[57,6975,6976],{},"Written communication quality."," Read the email threads and Slack history from past employers if you can. Can this person write clearly? Do they provide context, or do they expect you to know what they're referring to? Do they close loops?",[20,6979,6980,6983],{},[57,6981,6982],{},"Comfort with ambiguity."," Remote engineers often need to make progress without being able to immediately ask their question. Developers who stall when they hit a question they can't immediately resolve are more of a liability on a distributed team than on a co-located one.",[20,6985,6986,6989],{},[57,6987,6988],{},"Track record of shipping."," On a remote team, accountability is harder to enforce. Developers with a demonstrated track record of delivering on commitments require less management overhead. This is predictive of performance in a distributed environment.",[33,6991],{},[15,6993,6995],{"id":6994},"time-zones-the-variable-that-determines-so-much","Time Zones: The Variable That Determines So Much",[20,6997,6998],{},"A team spread across four time zones with no overlap is a fundamentally different operational challenge than a team with four hours of overlap per day. Neither is impossible — they just require different designs.",[20,7000,7001],{},"With no overlap, you need rigorous async discipline, clear handoff protocols, and acceptance that multi-party decisions take days, not hours. With meaningful overlap, you can run more frequent synchronous touchpoints and keep the async tooling simpler.",[20,7003,7004],{},"Be honest about what your actual overlap is and design your process accordingly. Teams that pretend they have more overlap than they do end up with a meeting schedule that works for some time zones and creates an access disadvantage for others.",[33,7006],{},[20,7008,7009],{},"Remote software development, when built on the right operational foundations, produces excellent outcomes. The teams I've seen fail at it weren't failing because of remote — they were failing because they hadn't designed their coordination systems for the constraints of the format.",[20,7011,7012,7013,582],{},"If you're building or managing a distributed engineering team and want to talk through the operational model, book a call at ",[171,7014,176],{"href":173,"rel":7015},[175],[33,7017],{},[15,7019,183],{"id":182},[185,7021,7022,7026,7030,7034],{},[188,7023,7024],{},[171,7025,205],{"href":204},[188,7027,7028],{},[171,7029,199],{"href":198},[188,7031,7032],{},[171,7033,193],{"href":192},[188,7035,7036],{},[171,7037,7039],{"href":7038},"/blog/software-project-management-guide","Software Project Management for Non-Technical Founders",{"title":213,"searchDepth":214,"depth":214,"links":7041},[7042,7043,7044,7045,7046,7047,7048,7049],{"id":6825,"depth":217,"text":6826},{"id":6837,"depth":217,"text":6838},{"id":6852,"depth":217,"text":6853},{"id":6882,"depth":217,"text":6883},{"id":6931,"depth":217,"text":6932},{"id":6967,"depth":217,"text":6968},{"id":6994,"depth":217,"text":6995},{"id":182,"depth":217,"text":183},"Remote development teams have a real coordination tax, but they also have real advantages when run well. Here's the system that makes distributed engineering work.",[7052,7053],"remote software development","remote developer",{},"/blog/remote-software-development",{"title":6819,"description":7050},"blog/remote-software-development",[7059,3326,7060],"Remote Work","Team Management","qIAY0OdF50mL25PcbH24vTCMJii4-9qhYQ6yGzfYu3c",{"id":7063,"title":7064,"author":7065,"body":7066,"category":3857,"date":225,"description":7508,"extension":227,"featured":228,"image":229,"keywords":7509,"meta":7516,"navigation":234,"path":7517,"readTime":314,"seo":7518,"stem":7519,"tags":7520,"__hash__":7524},"blog/blog/ross-surname-origin-meaning.md","The Ross Surname: Scottish Origins, Meaning, and Where the Name Came From",{"name":9,"bio":3332},{"type":12,"value":7067,"toc":7497},[7068,7072,7079,7085,7088,7090,7094,7104,7153,7156,7159,7161,7165,7168,7171,7174,7177,7183,7185,7189,7192,7195,7201,7215,7218,7221,7224,7226,7230,7236,7246,7255,7265,7271,7277,7279,7283,7286,7289,7295,7301,7307,7310,7312,7316,7328,7331,7342,7345,7350,7352,7356,7463,7466,7469,7471,7473],[15,7069,7071],{"id":7070},"the-name-and-the-land","The Name and the Land",[20,7073,7074,7075,7078],{},"The Ross surname is territorial in origin. It derives from the Scottish Gaelic word ",[3502,7076,7077],{},"ros"," — meaning a promontory, headland, or peninsula — and refers specifically to the territory of Ross-shire (now Easter Ross and part of the Highland council area) in the far north of Scotland.",[20,7080,7081,7082,7084],{},"The connection between the name and the land is direct and ancient. Ross-shire is defined by geography: it sits between the Cromarty Firth to the south, the Dornoch Firth to the east, and the Highland watershed to the west. It is a territory of headlands, peninsulas, and the rugged coastline of the Moray Firth. The Gaelic word ",[3502,7083,7077],{}," captures exactly what that land looks like from the water — a series of points projecting into the sea.",[20,7086,7087],{},"The surname \"Ross\" entered documentary record in the 13th century, when the earldom of Ross was formalised for Fearchar mac an t-Sagairt (Farquhar Son of the Priest) in 1215. Before the surname, the chiefs were known by patronymic or by reference to the territory. After the earldom, they were the Earls of Ross — and in time, Ross became a hereditary surname carried by the clan and by the extended family of dependants, followers, and tenants associated with the Ross territory.",[33,7089],{},[15,7091,7093],{"id":7092},"the-meaning-of-ross-across-scotland-and-ireland","The Meaning of \"Ross\" Across Scotland and Ireland",[20,7095,7096,7097,7099,7100,7103],{},"The place-name ",[3502,7098,7077],{}," / ",[3502,7101,7102],{},"ross"," appears widely across the Gaelic-speaking world:",[185,7105,7106,7112,7118,7130,7136,7147],{},[188,7107,7108,7111],{},[57,7109,7110],{},"Ross-shire"," — the Highland county, heart of clan territory",[188,7113,7114,7117],{},[57,7115,7116],{},"Ross of Mull"," — the southwestern peninsula of the Isle of Mull",[188,7119,7120,7123,7124,7126,7127],{},[57,7121,7122],{},"Rosemarkie"," — a village on the Black Isle, from ",[3502,7125,7077],{}," + ",[3502,7128,7129],{},"marcaidh",[188,7131,7132,7135],{},[57,7133,7134],{},"Roshven"," — a headland in Moidart",[188,7137,7138,7141,7142,7126,7144],{},[57,7139,7140],{},"Rosscarbery"," — a town in County Cork, Ireland, from ",[3502,7143,7077],{},[3502,7145,7146],{},"carbery",[188,7148,7149,7152],{},[57,7150,7151],{},"New Ross"," — County Wexford, Ireland, a fortified river crossing at a headland",[20,7154,7155],{},"The word is common to Scottish Gaelic and Irish Gaelic, both of which share the same root. This means \"Ross\" as a place-name element appears wherever Gaelic speakers settled and named the landscape — which is to say, much of Scotland, Ireland, and the islands.",[20,7157,7158],{},"As a surname, \"Ross\" is most concentrated in the northern Highlands (its point of origin), but spread with the Highland diaspora to every part of Scotland, then to Canada, the United States, Australia, and beyond. The Clearances of the late 18th and 19th centuries — particularly brutal in Ross-shire, where the Sutherland and Ross estates cleared tens of thousands of people — scattered the clan name across the English-speaking world.",[33,7160],{},[15,7162,7164],{"id":7163},"the-clan-history-and-lineage","The Clan: History and Lineage",[20,7166,7167],{},"The clan Ross traces its documented history back to the O'Beolan abbots of Applecross — a monastic community founded in 673 AD by St Maelrubha on the Applecross Peninsula in Ross-shire. The abbots of Applecross were hereditary, passing the office from father to son through a family that maintained both spiritual and secular authority in the region.",[20,7169,7170],{},"The traditional genealogy takes the chain further back:",[20,7172,7173],{},"From the O'Beolans, back through the Cenel Loairn — the \"kindred of Loarn,\" the ruling house of the northern division of Dal Riata. Dal Riata was the Irish-Scottish kingdom that straddled the North Channel, with territory in both County Antrim and the western Scottish islands and peninsula of Argyll.",[20,7175,7176],{},"From Cenel Loairn, back to Loarn mac Eirc — the eldest son of Erc, King of Dal Riata, who led the crossing from Ireland to Scotland around 500 AD. The traditional genealogy says Loarn was the elder brother of Fergus mor mac Eirc, who became the founding king of the Scottish Dal Riata and the ancestor of the Scottish royal line. Loarn took the northern provinces. Fergus took the crown.",[20,7178,7179,7180,7182],{},"From Loarn, the traditional genealogy continues back through the Irish king-lists to the Milesian kings — the legendary dynasty the ",[3502,7181,3504],{}," says descended from Mil Espaine (the Soldier of Spain) and his sons Éber Finn and Érimón, who invaded Ireland and established the dynasties.",[33,7184],{},[15,7186,7188],{"id":7187},"what-the-dna-says-about-ross-origins","What the DNA Says About Ross Origins",[20,7190,7191],{},"The traditional genealogy can only be verified back to the O'Beolans with reasonable confidence. The chain beyond that — through Cenel Loairn to Loarn mac Eirc to the Dal Riata kings to the Milesian ancestors — rests on medieval king-lists and genealogies that are plausible but not provable in their named specifics.",[20,7193,7194],{},"What DNA can do is test the broader pattern.",[20,7196,7197,7198,7200],{},"The Y-chromosome haplogroup carried by the Ross patriline — confirmed through testing of James R. Ross Jr. — is ",[57,7199,3344],{},", the Atlantic Celtic marker. This haplogroup is the molecular signature of the populations that:",[185,7202,7203,7206,7209,7212],{},[188,7204,7205],{},"Migrated westward from the Pontic-Caspian Steppe around 5,000 years ago",[188,7207,7208],{},"Arrived in the British Isles via the Bell Beaker archaeological culture, around 2500–2000 BC",[188,7210,7211],{},"Replaced the male lineage of the existing Neolithic populations of Ireland and Britain almost entirely",[188,7213,7214],{},"Became the populations that spoke the Celtic languages and built the hillforts, carved the La Tene metalwork, and established the kingdoms that became Ireland and Scotland",[20,7216,7217],{},"R1b-L21 is the dominant haplogroup in Ireland (roughly 80% of men), Scotland (similar frequencies in the Highlands and Islands), Wales, and Brittany. It is the genetic signature of the Gaelic and Brythonic Celtic world.",[20,7219,7220],{},"The absence of the M222 sub-marker in the Ross line is significant. M222 is associated with Niall of the Nine Hostages and the Uí Néill dynasty — the dominant Irish royal house from roughly 400 to 1200 AD. The Rosses don't carry M222, which means the Ross patriline diverged from the Uí Néill branch before M222 occurred. An older division. A parallel line.",[20,7222,7223],{},"The traditional claim of the clan — that the Rosses descend from the Senior Blood, the elder line, older than the dominant dynasties — finds at least suggestive support in the genetic evidence.",[33,7225],{},[15,7227,7229],{"id":7228},"the-clan-tartan-motto-and-symbols","The Clan Tartan, Motto, and Symbols",[20,7231,7232,7235],{},[57,7233,7234],{},"Tartan:"," The Ross tartan is primarily red (crimson), with crossing lines of green and navy on a white ground. Two main setts exist: the Ross Hunting tartan (darker, for field use) and the Ross Dress tartan (brighter red). The Ross Modern is a variant with more vibrant tones.",[20,7237,7238,7241,7242,7245],{},[57,7239,7240],{},"Motto:"," ",[3502,7243,7244],{},"Spem successus alit"," — \"Success nourishes hope.\" An apt motto for a clan whose history includes centuries of contesting with more powerful neighbors.",[20,7247,7248,7241,7251,7254],{},[57,7249,7250],{},"War cry:",[3502,7252,7253],{},"Spice Abundat"," (or in some sources, \"Bàs no Beatha\" — Death or Life, the standard Highland battle cry)",[20,7256,7257,7260,7261,7264],{},[57,7258,7259],{},"Badge:"," Juniper (",[3502,7262,7263],{},"Iuniperus communis","), common in the Highland landscape",[20,7266,7267,7270],{},[57,7268,7269],{},"Chief:"," The Chief of Clan Ross holds the traditional designation \"Ross of that Ilk\" — meaning the chief of the territorial family. The current chief is David Campbell Ross, 28th Chief of Clan Ross.",[20,7272,7273,7276],{},[57,7274,7275],{},"Clan seat:"," Balnagown Castle, Easter Ross, the ancestral seat of the Ross chiefs from the medieval period until the 17th century. The castle still exists, though it passed out of Ross ownership in 1672.",[33,7278],{},[15,7280,7282],{"id":7281},"clan-ross-in-the-diaspora","Clan Ross in the Diaspora",[20,7284,7285],{},"The Highland Clearances of the late 18th and 19th centuries were particularly severe in Ross-shire. Landlords — including, painfully, some of the Ross chiefs themselves — cleared the interior glens and coastal settlements of their tenants to convert the land to sheep farming and deer forest. Tens of thousands of people were displaced from Ross-shire alone.",[20,7287,7288],{},"The destinations:",[20,7290,7291,7294],{},[57,7292,7293],{},"Canada"," — Nova Scotia (literally \"New Scotland\"), Cape Breton Island, Prince Edward Island, and Ontario received enormous numbers of Highland emigrants. The Ross surname is common in Cape Breton in particular, where Gaelic was spoken until the early 20th century.",[20,7296,7297,7300],{},[57,7298,7299],{},"United States"," — North Carolina's Cape Fear Valley was an early destination for Highland emigrants, including Ross families, before the Revolution. Later, the Great Lakes region and the Prairie states.",[20,7302,7303,7306],{},[57,7304,7305],{},"Australia"," — Van Diemen's Land (Tasmania) and Victoria received transported Highlanders, including some displaced by the Clearances.",[20,7308,7309],{},"The Ross Family Association and Clan Ross Society maintain connections across the diaspora, and the Ross DNA project at FamilyTreeDNA aggregates genetic results from Ross men worldwide, allowing comparison across the surname.",[33,7311],{},[15,7313,7315],{"id":7314},"testing-your-ross-connection","Testing Your Ross Connection",[20,7317,7318,7319,7322,7323,7327],{},"If you carry the Ross surname or believe you have Ross ancestry, the most informative genetic test is a Y-chromosome paternal line test through ",[171,7320,3582],{"href":3580,"rel":7321},[175],". The ",[171,7324,7326],{"href":3607,"rel":7325},[175],"Ross Surname DNA Project at FamilyTreeDNA"," allows you to compare your results with other men carrying the Ross name and with the growing database of tested members.",[20,7329,7330],{},"What to expect:",[185,7332,7333,7336,7339],{},[188,7334,7335],{},"If you carry R1b-L21, you share the broad Atlantic Celtic lineage with the traditional Ross patriline",[188,7337,7338],{},"The absence of M222 in a tested Ross male would be consistent with the pattern found in the primary Ross line",[188,7340,7341],{},"The FamilyTreeDNA project groups results by haplogroup and identifies clusters that may represent different origins for the Ross surname (not all Rosses share the same patrilineal origin — the name was taken by different families in different places)",[20,7343,7344],{},"For the deeper story — what R1b-L21 means, where it came from, how it connects to the traditional Milesian genealogy, and how the Ross clan fits into 22,000 years of human migration — that's the subject of my book.",[20,7346,7347],{},[171,7348,7349],{"href":3691},"Read more about The Forge of Tongues: 22,000 Years of Migration, Mutation, and Memory.",[33,7351],{},[15,7353,7355],{"id":7354},"key-facts-the-ross-surname","Key Facts: The Ross Surname",[3700,7357,7358,7366],{},[3703,7359,7360],{},[3706,7361,7362,7364],{},[3709,7363],{},[3709,7365],{},[3714,7367,7368,7381,7391,7401,7411,7423,7433,7443,7453],{},[3706,7369,7370,7375],{},[3719,7371,7372],{},[57,7373,7374],{},"Origin",[3719,7376,7377,7378,7380],{},"Scottish Gaelic ",[3502,7379,7077],{}," (headland/promontory)",[3706,7382,7383,7388],{},[3719,7384,7385],{},[57,7386,7387],{},"First recorded",[3719,7389,7390],{},"13th century (Earldom of Ross, 1215)",[3706,7392,7393,7398],{},[3719,7394,7395],{},[57,7396,7397],{},"Clan territory",[3719,7399,7400],{},"Ross-shire, northern Scottish Highlands",[3706,7402,7403,7408],{},[3719,7404,7405],{},[57,7406,7407],{},"Chief's designation",[3719,7409,7410],{},"Ross of that Ilk",[3706,7412,7413,7418],{},[3719,7414,7415],{},[57,7416,7417],{},"Motto",[3719,7419,7420,7422],{},[3502,7421,7244],{}," — Success nourishes hope",[3706,7424,7425,7430],{},[3719,7426,7427],{},[57,7428,7429],{},"Tartan",[3719,7431,7432],{},"Crimson, green, navy on white ground",[3706,7434,7435,7440],{},[3719,7436,7437],{},[57,7438,7439],{},"Badge",[3719,7441,7442],{},"Juniper",[3706,7444,7445,7450],{},[3719,7446,7447],{},[57,7448,7449],{},"Y-chromosome haplogroup",[3719,7451,7452],{},"R1b-L21 (Atlantic Celtic)",[3706,7454,7455,7460],{},[3719,7456,7457],{},[57,7458,7459],{},"Diaspora concentrations",[3719,7461,7462],{},"Cape Breton (Canada), North Carolina, Scotland",[20,7464,7465],{},"The Ross name is old. The blood behind it is older. The haplogroup that ties living Ross men to the Steppe, to the Bell Beaker expansion, to the Milesian invasion of Ireland — that string of letters is the oldest document the Ross family possesses.",[20,7467,7468],{},"And it's written in every cell.",[33,7470],{},[15,7472,3818],{"id":3817},[185,7474,7475,7481,7487,7491],{},[188,7476,7477],{},[171,7478,7480],{"href":7479},"/blog/fearchar-mac-an-t-sagairt-earl-ross","Fearchar mac an t-Sagairt: The First Earl of Ross",[188,7482,7483],{},[171,7484,7486],{"href":7485},"/blog/highland-clearances-clan-ross-diaspora","The Highland Clearances and Clan Ross: How a People Were Scattered",[188,7488,7489],{},[171,7490,3330],{"href":3868},[188,7492,7493],{},[171,7494,7496],{"href":7495},"/blog/loarn-mac-eirc-elder-brother","Loarn mac Eirc: The Elder Brother and the Senior Blood",{"title":213,"searchDepth":214,"depth":214,"links":7498},[7499,7500,7501,7502,7503,7504,7505,7506,7507],{"id":7070,"depth":217,"text":7071},{"id":7092,"depth":217,"text":7093},{"id":7163,"depth":217,"text":7164},{"id":7187,"depth":217,"text":7188},{"id":7228,"depth":217,"text":7229},{"id":7281,"depth":217,"text":7282},{"id":7314,"depth":217,"text":7315},{"id":7354,"depth":217,"text":7355},{"id":3817,"depth":217,"text":3818},"The Ross surname is one of Scotland's oldest territorial names, derived from the Gaelic 'ros' meaning headland. But the bloodline behind the name is 22,000 years older than the name itself. Here's the full story.",[7510,7511,7512,7513,7514,7515],"ross surname origin","ross surname origin meaning","clan ross","clan ross history","scottish ancestry surnames","clan ross tartan",{},"/blog/ross-surname-origin-meaning",{"title":7064,"description":7508},"blog/ross-surname-origin-meaning",[7521,3876,3875,7522,7523],"Ross Surname","Scottish Clan History","Genealogy","cN60JBEs-CP9kkLyrKJn-fd8lO_g0O7tvPmD90UJ5q4",{"id":7526,"title":2531,"author":7527,"body":7528,"category":2154,"date":225,"description":7775,"extension":227,"featured":228,"image":229,"keywords":7776,"meta":7779,"navigation":234,"path":2530,"readTime":236,"seo":7780,"stem":7781,"tags":7782,"__hash__":7784},"blog/blog/saas-development-guide.md",{"name":9,"bio":10},{"type":12,"value":7529,"toc":7766},[7530,7534,7537,7540,7543,7545,7549,7552,7555,7561,7567,7580,7583,7585,7589,7592,7598,7604,7610,7613,7615,7619,7622,7625,7631,7637,7643,7649,7655,7661,7663,7667,7673,7679,7689,7691,7695,7701,7711,7717,7727,7733,7735,7742,7744,7746],[15,7531,7533],{"id":7532},"the-decisions-that-determine-everything-else","The Decisions That Determine Everything Else",[20,7535,7536],{},"Most SaaS advice focuses on marketing, pricing, and customer acquisition. That's important, but it's the wrong starting point for a developer building a SaaS product. Before you can acquire customers, you need to make a set of technical decisions that will constrain everything you build for the next several years.",[20,7538,7539],{},"Get these decisions right — or at least not catastrophically wrong — and the product can evolve as you learn. Get them badly wrong and you'll face an expensive, demoralizing rewrite at exactly the moment when you should be focused on growth.",[20,7541,7542],{},"This article walks through the architecture decisions, the development sequence, and the common mistakes I've seen derail SaaS products from someone who has built several of them.",[33,7544],{},[15,7546,7548],{"id":7547},"multi-tenancy-the-core-architectural-question","Multi-Tenancy: The Core Architectural Question",[20,7550,7551],{},"The most important architectural decision in a SaaS product is how you handle multi-tenancy — how your single application serves multiple customers while keeping their data isolated.",[20,7553,7554],{},"There are three primary models:",[20,7556,7557,7560],{},[57,7558,7559],{},"Database per tenant."," Each customer gets their own database. Data isolation is absolute, and compliance is easier to reason about. The trade-off is operational complexity: managing dozens or hundreds of databases, running migrations across all of them, and the overhead of database connection pooling.",[20,7562,7563,7566],{},[57,7564,7565],{},"Schema per tenant (PostgreSQL)."," Each customer gets their own schema within a shared database. Good middle ground — strong isolation without the full operational burden of separate databases. Migrations are still complex but manageable with the right tooling.",[20,7568,7569,7572,7573,7576,7577,7579],{},[57,7570,7571],{},"Row-level tenant isolation."," All customers share tables, and every row has a ",[95,7574,7575],{},"tenant_id"," foreign key. Migrations are simple. Operational overhead is minimal. The risk is developer error — a query that forgets the ",[95,7578,7575],{}," filter exposes one customer's data to another. Row-level security (RLS) in PostgreSQL mitigates this substantially if you enforce it at the database level rather than the application level.",[20,7581,7582],{},"For most early-stage SaaS products, I recommend row-level isolation with PostgreSQL RLS enforced at the database level. The operational simplicity allows you to move faster early on, and the security can be upgraded to schema-per-tenant if compliance requirements demand it later.",[33,7584],{},[15,7586,7588],{"id":7587},"the-stack-decision","The Stack Decision",[20,7590,7591],{},"There are more good SaaS stacks in 2026 than there have ever been, which means the stack decision is less likely to be catastrophic than it was ten years ago. That said, some principles hold:",[20,7593,7594,7597],{},[57,7595,7596],{},"Use something you know, unless there's a compelling reason not to."," The productivity advantage of working in a familiar stack is enormous in the early stages when you're moving fast. Switching to a new stack because it's more theoretically correct adds learning overhead at the worst possible time.",[20,7599,7600,7603],{},[57,7601,7602],{},"Prioritize the ecosystem over the technology."," A framework with an active community, good documentation, and a strong library ecosystem will serve you better than a technically superior framework with limited support. When you hit an edge case, the ecosystem determines how long you spend stuck.",[20,7605,7606,7609],{},[57,7607,7608],{},"Separate your data layer from your API layer from your UI early."," Even if you start with a monolith (which is fine), having a clear boundary between these layers makes it vastly easier to extract services later. A well-structured monolith is much easier to evolve than spaghetti architecture.",[20,7611,7612],{},"My current default for early-stage SaaS: TypeScript throughout, Hono or Express on the backend, PostgreSQL with Prisma, and Nuxt or Next for the frontend. Not because these are the best possible choices — because they're excellent choices with massive ecosystems and a developer pool that isn't artificially constrained.",[33,7614],{},[15,7616,7618],{"id":7617},"the-development-sequence-that-works","The Development Sequence That Works",[20,7620,7621],{},"Most SaaS products get built in the wrong order. The founders are excited about the core product feature and want to build it first. But you'll demo to investors and first customers before the product is \"finished,\" and the first thing they'll encounter is your auth system and onboarding flow, not the core feature.",[20,7623,7624],{},"Build in this order:",[20,7626,7627,7630],{},[57,7628,7629],{},"1. Authentication and user management."," Email/password login, social auth if relevant, password reset, session management. Use a library (better-auth, Auth.js, Lucia) rather than rolling your own. This is not where you want to be creative.",[20,7632,7633,7636],{},[57,7634,7635],{},"2. Multi-tenancy and billing infrastructure."," Your tenant data model, organization management (if B2B), and Stripe integration for subscription billing. Getting this wrong requires a data migration to fix. Get it right first.",[20,7638,7639,7642],{},[57,7640,7641],{},"3. Core product loop."," The thing your product actually does. The minimum version of it that provides genuine value to a real user.",[20,7644,7645,7648],{},[57,7646,7647],{},"4. Admin and management surfaces."," How you manage users, handle support, and view system state internally. You'll need this earlier than you think.",[20,7650,7651,7654],{},[57,7652,7653],{},"5. Retention and engagement features."," Email notifications, in-app messaging, user activity views. Only after the core loop is solid.",[20,7656,7657,7660],{},[57,7658,7659],{},"6. Growth features."," Referrals, invitations, team collaboration features that help the product spread.",[33,7662],{},[15,7664,7666],{"id":7665},"the-apis-youll-regret-not-planning-for","The APIs You'll Regret Not Planning For",[20,7668,7669,7672],{},[57,7670,7671],{},"Event system."," Every meaningful user action should emit an event — user signed up, project created, feature activated, subscription upgraded. This event stream is what powers your analytics, your email automation, your admin dashboard, and eventually your billing. Build a simple event system from the start and emit events consistently.",[20,7674,7675,7678],{},[57,7676,7677],{},"Webhooks."," B2B customers will want webhooks to integrate your product with their systems. Plan for this in your data model even if you don't build it in v1.",[20,7680,7681,7684,7685,7688],{},[57,7682,7683],{},"API versioning."," If you're exposing a public API, version it from day one. ",[95,7686,7687],{},"/api/v1/"," in the URL. You'll make breaking changes. Version control means those changes don't break existing integrations.",[33,7690],{},[15,7692,7694],{"id":7693},"the-mistakes-that-derail-saas-products","The Mistakes That Derail SaaS Products",[20,7696,7697,7700],{},[57,7698,7699],{},"Over-engineering before customer validation."," Building a microservice architecture before you have ten customers is a way to spend a lot of time on infrastructure that doesn't help you learn whether people want the product. Start simple. Optimize later.",[20,7702,7703,7706,7707,7710],{},[57,7704,7705],{},"Skipping error handling and monitoring."," Production software fails. ",[95,7708,7709],{},"console.error"," is not a monitoring strategy. Set up Sentry or equivalent from day one. Know when things break before your customers do.",[20,7712,7713,7716],{},[57,7714,7715],{},"Ignoring the data model."," The data model is the hardest thing to change after customers are using your product. Think it through carefully before you write the first migration. The entities, their relationships, the fields that will be needed for future features — all of this is easier to reason about on a whiteboard before there's live data.",[20,7718,7719,7722,7723,7726],{},[57,7720,7721],{},"Not building for soft deletes."," Users delete things accidentally. Build your delete operations as soft deletes (set ",[95,7724,7725],{},"deleted_at",", filter in queries) from the start. Adding this later to a system with hard deletes is painful.",[20,7728,7729,7732],{},[57,7730,7731],{},"Authentication as an afterthought."," Tacking auth onto a system that was built without it is significantly harder than building with auth in mind from the start. Know who the actor is in every operation before you build the operation.",[33,7734],{},[20,7736,7737,7738,7741],{},"Building a SaaS product that survives contact with real customers requires getting a short list of foundational decisions right before the code gets too tangled to change. If you're starting a SaaS project and want to talk through the architecture before you build, book a session at ",[171,7739,176],{"href":173,"rel":7740},[175]," — catching these early is significantly cheaper than fixing them later.",[33,7743],{},[15,7745,183],{"id":182},[185,7747,7748,7752,7756,7762],{},[188,7749,7750],{},[171,7751,2549],{"href":2548},[188,7753,7754],{},[171,7755,2170],{"href":2568},[188,7757,7758],{},[171,7759,7761],{"href":7760},"/blog/saas-feature-flags","Feature Flags in SaaS: Shipping Safely and Testing in Production",[188,7763,7764],{},[171,7765,2543],{"href":2542},{"title":213,"searchDepth":214,"depth":214,"links":7767},[7768,7769,7770,7771,7772,7773,7774],{"id":7532,"depth":217,"text":7533},{"id":7547,"depth":217,"text":7548},{"id":7587,"depth":217,"text":7588},{"id":7617,"depth":217,"text":7618},{"id":7665,"depth":217,"text":7666},{"id":7693,"depth":217,"text":7694},{"id":182,"depth":217,"text":183},"Building a SaaS product involves technical decisions that have permanent consequences. Here's the guide I wish I'd had — the architecture, stack, and sequencing that works.",[7777,7778],"SaaS development guide","building SaaS",{},{"title":2531,"description":7775},"blog/saas-development-guide",[2573,2574,7783],"Product Development","gSHWlTl52zVe1KVqo5C6Kj8RVlJv9JKjwXl9KQnjFTM",{"id":7786,"title":7761,"author":7787,"body":7788,"category":2154,"date":225,"description":8496,"extension":227,"featured":228,"image":229,"keywords":8497,"meta":8500,"navigation":234,"path":7760,"readTime":236,"seo":8501,"stem":8502,"tags":8503,"__hash__":8506},"blog/blog/saas-feature-flags.md",{"name":9,"bio":10},{"type":12,"value":7789,"toc":8486},[7790,7794,7797,7800,7802,7806,7809,7815,7821,7827,7833,7835,7839,7842,7847,7913,7918,8151,8154,8160,8162,8166,8169,8175,8181,8187,8193,8196,8198,8202,8209,8212,8223,8226,8237,8244,8246,8250,8253,8256,8431,8434,8436,8440,8443,8446,8449,8451,8457,8459,8461,8483],[15,7791,7793],{"id":7792},"decoupling-deployment-from-release","Decoupling Deployment From Release",[20,7795,7796],{},"The most useful shift in thinking about software deployment is separating \"deploy\" from \"release.\" Deploying means getting code into production. Releasing means making a feature available to users. These can — and often should — happen at different times.",[20,7798,7799],{},"Feature flags are the mechanism that makes this possible. With feature flags, you can deploy code continuously (which keeps branches short and integration conflict-free) while controlling precisely who sees each feature and when. This capability enables safer deployments, controlled rollouts, A/B testing, instant rollbacks, and environment-specific behavior — all without branching complexity.",[33,7801],{},[15,7803,7805],{"id":7804},"the-types-of-feature-flags","The Types of Feature Flags",[20,7807,7808],{},"Not all feature flags are the same. Confusing them leads to a messy flag system that accumulates flags that should have been removed months ago.",[20,7810,7811,7814],{},[57,7812,7813],{},"Release flags."," The most common type. A feature is behind a flag while it's in development, turned on for internal testing, then progressively rolled out to users. Once the rollout is complete, the flag should be removed from the code. Release flags are temporary by design.",[20,7816,7817,7820],{},[57,7818,7819],{},"Operational flags."," Controls for system behavior that might need to be adjusted in response to conditions — kill switches for expensive features under load, rate limiting toggles, cache behavior settings. These are permanent flags with long lifespans. They're not releases; they're operational controls.",[20,7822,7823,7826],{},[57,7824,7825],{},"Experiment flags."," A/B test flags that show different variants to different user segments, used to test the impact of a change before full release. These are temporary, and they should have a defined end date: the experiment runs for X weeks, you analyze the results, and the winning variant becomes the default.",[20,7828,7829,7832],{},[57,7830,7831],{},"Permission flags."," Used to gate features by plan or role. \"This feature is available on Professional plan and above.\" These overlap with your billing/permission system and are typically longer-lived than release flags.",[33,7834],{},[15,7836,7838],{"id":7837},"building-a-simple-feature-flag-system","Building a Simple Feature Flag System",[20,7840,7841],{},"For an early-stage SaaS, a managed service (LaunchDarkly, Unleash, Flagsmith) is often overkill. A simple in-house implementation works well until you have complex targeting requirements.",[20,7843,7844],{},[57,7845,7846],{},"Database schema:",[264,7848,7850],{"className":2228,"code":7849,"language":2230,"meta":213,"style":213},"feature_flags (\n id, key, -- unique identifier: 'new_dashboard', 'beta_csv_export'\n enabled, -- global on/off switch\n rollout_percentage, -- 0-100, percentage of users to enable for\n description,\n created_at, updated_at\n)\n\nFeature_flag_overrides (\n id, flag_id, entity_type, -- 'user', 'organization', 'plan'\n entity_id, enabled,\n created_at\n)\n",[95,7851,7852,7857,7862,7867,7872,7877,7882,7886,7890,7895,7900,7905,7909],{"__ignoreMap":213},[272,7853,7854],{"class":274,"line":275},[272,7855,7856],{},"feature_flags (\n",[272,7858,7859],{"class":274,"line":217},[272,7860,7861],{}," id, key, -- unique identifier: 'new_dashboard', 'beta_csv_export'\n",[272,7863,7864],{"class":274,"line":214},[272,7865,7866],{}," enabled, -- global on/off switch\n",[272,7868,7869],{"class":274,"line":291},[272,7870,7871],{}," rollout_percentage, -- 0-100, percentage of users to enable for\n",[272,7873,7874],{"class":274,"line":297},[272,7875,7876],{}," description,\n",[272,7878,7879],{"class":274,"line":303},[272,7880,7881],{}," created_at, updated_at\n",[272,7883,7884],{"class":274,"line":236},[272,7885,1368],{},[272,7887,7888],{"class":274,"line":314},[272,7889,300],{"emptyLinePlaceholder":234},[272,7891,7892],{"class":274,"line":320},[272,7893,7894],{},"Feature_flag_overrides (\n",[272,7896,7897],{"class":274,"line":325},[272,7898,7899],{}," id, flag_id, entity_type, -- 'user', 'organization', 'plan'\n",[272,7901,7902],{"class":274,"line":330},[272,7903,7904],{}," entity_id, enabled,\n",[272,7906,7907],{"class":274,"line":336},[272,7908,2371],{},[272,7910,7911],{"class":274,"line":342},[272,7912,1368],{},[20,7914,7915],{},[57,7916,7917],{},"Evaluation logic (TypeScript):",[264,7919,7921],{"className":606,"code":7920,"language":608,"meta":213,"style":213},"async function isEnabled(\n flagKey: string,\n context: { userId: string; organizationId: string; plan: string }\n): Promise\u003Cboolean> {\n const flag = await getFlag(flagKey)\n if (!flag) return false\n\n // Check overrides first (highest priority)\n const override = await getOverride(flag.id, context)\n if (override !== null) return override.enabled\n\n // Global kill switch\n if (!flag.enabled) return false\n\n // Percentage rollout (deterministic by userId)\n const hash = hashStringToNumber(flagKey + context.userId)\n return (hash % 100) \u003C flag.rollout_percentage\n}\n",[95,7922,7923,7934,7945,7981,7996,8013,8029,8033,8038,8055,8075,8079,8084,8099,8103,8108,8128,8147],{"__ignoreMap":213},[272,7924,7925,7927,7929,7932],{"class":274,"line":275},[272,7926,1216],{"class":621},[272,7928,1219],{"class":621},[272,7930,7931],{"class":673}," isEnabled",[272,7933,1225],{"class":625},[272,7935,7936,7939,7941,7943],{"class":274,"line":217},[272,7937,7938],{"class":666}," flagKey",[272,7940,670],{"class":621},[272,7942,1235],{"class":654},[272,7944,924],{"class":625},[272,7946,7947,7950,7952,7955,7957,7959,7961,7963,7966,7968,7970,7972,7975,7977,7979],{"class":274,"line":214},[272,7948,7949],{"class":666}," context",[272,7951,670],{"class":621},[272,7953,7954],{"class":625}," { ",[272,7956,4479],{"class":666},[272,7958,670],{"class":621},[272,7960,1235],{"class":654},[272,7962,2683],{"class":625},[272,7964,7965],{"class":666},"organizationId",[272,7967,670],{"class":621},[272,7969,1235],{"class":654},[272,7971,2683],{"class":625},[272,7973,7974],{"class":666},"plan",[272,7976,670],{"class":621},[272,7978,1235],{"class":654},[272,7980,1373],{"class":625},[272,7982,7983,7985,7987,7989,7991,7994],{"class":274,"line":291},[272,7984,1263],{"class":625},[272,7986,670],{"class":621},[272,7988,1268],{"class":673},[272,7990,1271],{"class":625},[272,7992,7993],{"class":654},"boolean",[272,7995,1277],{"class":625},[272,7997,7998,8000,8003,8005,8007,8010],{"class":274,"line":297},[272,7999,1094],{"class":621},[272,8001,8002],{"class":654}," flag",[272,8004,858],{"class":621},[272,8006,861],{"class":621},[272,8008,8009],{"class":673}," getFlag",[272,8011,8012],{"class":625},"(flagKey)\n",[272,8014,8015,8017,8019,8021,8024,8026],{"class":274,"line":303},[272,8016,1342],{"class":621},[272,8018,1078],{"class":625},[272,8020,4255],{"class":621},[272,8022,8023],{"class":625},"flag) ",[272,8025,6294],{"class":621},[272,8027,8028],{"class":654}," false\n",[272,8030,8031],{"class":274,"line":236},[272,8032,300],{"emptyLinePlaceholder":234},[272,8034,8035],{"class":274,"line":314},[272,8036,8037],{"class":615}," // Check overrides first (highest priority)\n",[272,8039,8040,8042,8045,8047,8049,8052],{"class":274,"line":320},[272,8041,1094],{"class":621},[272,8043,8044],{"class":654}," override",[272,8046,858],{"class":621},[272,8048,861],{"class":621},[272,8050,8051],{"class":673}," getOverride",[272,8053,8054],{"class":625},"(flag.id, context)\n",[272,8056,8057,8059,8062,8065,8068,8070,8072],{"class":274,"line":325},[272,8058,1342],{"class":621},[272,8060,8061],{"class":625}," (override ",[272,8063,8064],{"class":621},"!==",[272,8066,8067],{"class":654}," null",[272,8069,1300],{"class":625},[272,8071,6294],{"class":621},[272,8073,8074],{"class":625}," override.enabled\n",[272,8076,8077],{"class":274,"line":330},[272,8078,300],{"emptyLinePlaceholder":234},[272,8080,8081],{"class":274,"line":336},[272,8082,8083],{"class":615}," // Global kill switch\n",[272,8085,8086,8088,8090,8092,8095,8097],{"class":274,"line":342},[272,8087,1342],{"class":621},[272,8089,1078],{"class":625},[272,8091,4255],{"class":621},[272,8093,8094],{"class":625},"flag.enabled) ",[272,8096,6294],{"class":621},[272,8098,8028],{"class":654},[272,8100,8101],{"class":274,"line":348},[272,8102,300],{"emptyLinePlaceholder":234},[272,8104,8105],{"class":274,"line":354},[272,8106,8107],{"class":615}," // Percentage rollout (deterministic by userId)\n",[272,8109,8110,8112,8115,8117,8120,8123,8125],{"class":274,"line":360},[272,8111,1094],{"class":621},[272,8113,8114],{"class":654}," hash",[272,8116,858],{"class":621},[272,8118,8119],{"class":673}," hashStringToNumber",[272,8121,8122],{"class":625},"(flagKey ",[272,8124,6122],{"class":621},[272,8126,8127],{"class":625}," context.userId)\n",[272,8129,8130,8132,8135,8138,8140,8142,8144],{"class":274,"line":366},[272,8131,1946],{"class":621},[272,8133,8134],{"class":625}," (hash ",[272,8136,8137],{"class":621},"%",[272,8139,1880],{"class":654},[272,8141,1300],{"class":625},[272,8143,1271],{"class":621},[272,8145,8146],{"class":625}," flag.rollout_percentage\n",[272,8148,8149],{"class":274,"line":372},[272,8150,294],{"class":625},[20,8152,8153],{},"The hashing ensures a user gets a consistent experience (always in or always out for a given flag) rather than a random experience on each request.",[20,8155,8156,8159],{},[57,8157,8158],{},"Caching."," Flag evaluations happen on every request for flagged features. Cache flag state aggressively — Redis with a 30-60 second TTL is typical. Stale flags for 30 seconds is a minor inconvenience. Querying the database for every flag on every request is a performance problem.",[33,8161],{},[15,8163,8165],{"id":8164},"progressive-rollout-in-practice","Progressive Rollout in Practice",[20,8167,8168],{},"The typical rollout progression for a new feature:",[20,8170,8171,8174],{},[57,8172,8173],{},"Internal only (0%, with overrides for the team)."," The feature is deployed but visible only to internal users. You're testing that it works in production, not that it works in your local environment.",[20,8176,8177,8180],{},[57,8178,8179],{},"Alpha (5-10%)."," A small percentage of users see the feature. You're looking for error rate spikes, performance regressions, and unexpected edge cases. Monitor error reporting and performance dashboards continuously.",[20,8182,8183,8186],{},[57,8184,8185],{},"Beta (25-50%)."," Broader exposure. Collect user feedback. Monitor business metrics (are feature users converting at the same rate? Are they churning less?). A/B analysis begins.",[20,8188,8189,8192],{},[57,8190,8191],{},"General availability (100%)."," Full rollout. The flag remains in place for one to two sprints as a kill switch, then gets removed from the code.",[20,8194,8195],{},"The \"kill switch\" period is important. If something goes sideways after a full rollout, you want to be able to disable the feature in 30 seconds (by toggling the flag) rather than deploying a revert. The flag stays in place until you're confident the feature is stable.",[33,8197],{},[15,8199,8201],{"id":8200},"flag-hygiene-the-problem-nobody-talks-about","Flag Hygiene: The Problem Nobody Talks About",[20,8203,8204,8205,8208],{},"Flags accumulate. Teams add them for releases and then forget to remove them when the release is complete. Two years later, the codebase is full of conditions like ",[95,8206,8207],{},"if (featureFlags.isEnabled('new_checkout_flow', user))"," for a checkout flow that shipped in 2024 and is now the only checkout flow.",[20,8210,8211],{},"Dead flags are technical debt that:",[185,8213,8214,8217,8220],{},[188,8215,8216],{},"Makes code harder to read (more branching conditions)",[188,8218,8219],{},"Creates confusion about what behavior is actually in production",[188,8221,8222],{},"Occasionally causes real bugs when someone assumes a flag is off that's actually on",[20,8224,8225],{},"Establish a flag cleanup discipline:",[185,8227,8228,8231,8234],{},[188,8229,8230],{},"Every release flag gets a \"remove by\" date set in the flag description at creation time",[188,8232,8233],{},"Sprint review includes a check of flags past their remove-by date",[188,8235,8236],{},"Removing a completed flag is a discrete task that gets estimated and assigned",[20,8238,8239,8240,8243],{},"LaunchDarkly and similar tools have built-in flag age tracking and stale flag alerts. If you're building your own system, add a ",[95,8241,8242],{},"remove_by"," field to the flag table and build an internal dashboard that surfaces stale flags.",[33,8245],{},[15,8247,8249],{"id":8248},"testing-with-feature-flags","Testing With Feature Flags",[20,8251,8252],{},"Feature flags create a testing complexity: your code now has branches, and each branch needs test coverage. A naive approach is to test only the \"flag enabled\" path. This leaves the \"flag disabled\" path untested, which means when you eventually remove the flag and delete the old branch, you didn't know if the removal broke anything.",[20,8254,8255],{},"The right approach: test both states explicitly.",[264,8257,8259],{"className":606,"code":8258,"language":608,"meta":213,"style":213},"describe('Invoice export', () => {\n it('shows CSV export option when flag is enabled', async () => {\n mockFeatureFlag('csv_export', true)\n render(\u003CInvoicePage />)\n expect(screen.getByText('Export to CSV')).toBeInTheDocument()\n })\n\n it('hides CSV export option when flag is disabled', async () => {\n mockFeatureFlag('csv_export', false)\n render(\u003CInvoicePage />)\n expect(screen.queryByText('Export to CSV')).not.toBeInTheDocument()\n })\n})\n",[95,8260,8261,8277,8297,8313,8327,8351,8355,8359,8378,8393,8403,8423,8427],{"__ignoreMap":213},[272,8262,8263,8266,8268,8271,8273,8275],{"class":274,"line":275},[272,8264,8265],{"class":673},"describe",[272,8267,1290],{"class":625},[272,8269,8270],{"class":632},"'Invoice export'",[272,8272,2784],{"class":625},[272,8274,1303],{"class":621},[272,8276,661],{"class":625},[272,8278,8279,8282,8284,8287,8289,8291,8293,8295],{"class":274,"line":217},[272,8280,8281],{"class":673}," it",[272,8283,1290],{"class":625},[272,8285,8286],{"class":632},"'shows CSV export option when flag is enabled'",[272,8288,752],{"class":625},[272,8290,1216],{"class":621},[272,8292,5063],{"class":625},[272,8294,1303],{"class":621},[272,8296,661],{"class":625},[272,8298,8299,8302,8304,8307,8309,8311],{"class":274,"line":214},[272,8300,8301],{"class":673}," mockFeatureFlag",[272,8303,1290],{"class":625},[272,8305,8306],{"class":632},"'csv_export'",[272,8308,752],{"class":625},[272,8310,876],{"class":654},[272,8312,1368],{"class":625},[272,8314,8315,8318,8321,8324],{"class":274,"line":291},[272,8316,8317],{"class":673}," render",[272,8319,8320],{"class":625},"(\u003C",[272,8322,8323],{"class":673},"InvoicePage",[272,8325,8326],{"class":625}," />)\n",[272,8328,8329,8332,8335,8338,8340,8343,8346,8349],{"class":274,"line":297},[272,8330,8331],{"class":673}," expect",[272,8333,8334],{"class":625},"(screen.",[272,8336,8337],{"class":673},"getByText",[272,8339,1290],{"class":625},[272,8341,8342],{"class":632},"'Export to CSV'",[272,8344,8345],{"class":625},")).",[272,8347,8348],{"class":673},"toBeInTheDocument",[272,8350,1070],{"class":625},[272,8352,8353],{"class":274,"line":303},[272,8354,780],{"class":625},[272,8356,8357],{"class":274,"line":236},[272,8358,300],{"emptyLinePlaceholder":234},[272,8360,8361,8363,8365,8368,8370,8372,8374,8376],{"class":274,"line":314},[272,8362,8281],{"class":673},[272,8364,1290],{"class":625},[272,8366,8367],{"class":632},"'hides CSV export option when flag is disabled'",[272,8369,752],{"class":625},[272,8371,1216],{"class":621},[272,8373,5063],{"class":625},[272,8375,1303],{"class":621},[272,8377,661],{"class":625},[272,8379,8380,8382,8384,8386,8388,8391],{"class":274,"line":320},[272,8381,8301],{"class":673},[272,8383,1290],{"class":625},[272,8385,8306],{"class":632},[272,8387,752],{"class":625},[272,8389,8390],{"class":654},"false",[272,8392,1368],{"class":625},[272,8394,8395,8397,8399,8401],{"class":274,"line":325},[272,8396,8317],{"class":673},[272,8398,8320],{"class":625},[272,8400,8323],{"class":673},[272,8402,8326],{"class":625},[272,8404,8405,8407,8409,8412,8414,8416,8419,8421],{"class":274,"line":330},[272,8406,8331],{"class":673},[272,8408,8334],{"class":625},[272,8410,8411],{"class":673},"queryByText",[272,8413,1290],{"class":625},[272,8415,8342],{"class":632},[272,8417,8418],{"class":625},")).not.",[272,8420,8348],{"class":673},[272,8422,1070],{"class":625},[272,8424,8425],{"class":274,"line":336},[272,8426,780],{"class":625},[272,8428,8429],{"class":274,"line":342},[272,8430,884],{"class":625},[20,8432,8433],{},"Your test utilities need to support mocking flag state. This is a small investment that pays off across every flagged feature.",[33,8435],{},[15,8437,8439],{"id":8438},"when-to-use-a-managed-flag-service","When to Use a Managed Flag Service",[20,8441,8442],{},"If your team reaches a point where you're running multiple simultaneous experiments, have complex user targeting requirements (enable for users in specific geographies, with specific attributes, on specific account types), or need real-time flag changes without a code deployment, a managed service becomes worth the cost.",[20,8444,8445],{},"LaunchDarkly is the market leader. Unleash is a capable open-source alternative you can self-host. Flagsmith sits between the two in terms of features and price. Any of them will give you a better targeting UI, audit logs, and real-time flag evaluation than a home-built solution.",[20,8447,8448],{},"The migration path from a simple home-built system to a managed service is straightforward if you've abstracted your flag evaluation behind a clean interface — which is another argument for the abstraction layer from the start.",[33,8450],{},[20,8452,8453,8454,582],{},"Feature flags are one of the practices that separate teams who ship confidently from teams who treat every deployment as a gamble. If you're building the deployment infrastructure for a SaaS product and want to talk through the approach, book a call at ",[171,8455,176],{"href":173,"rel":8456},[175],[33,8458],{},[15,8460,183],{"id":182},[185,8462,8463,8469,8473,8477],{},[188,8464,8465],{},[171,8466,8468],{"href":8467},"/blog/saas-vs-on-premise","SaaS vs On-Premise Enterprise Software: How to Make the Right Call",[188,8470,8471],{},[171,8472,2531],{"href":2530},[188,8474,8475],{},[171,8476,2543],{"href":2542},[188,8478,8479],{},[171,8480,8482],{"href":8481},"/blog/saas-security-guide","SaaS Security: The Non-Negotiables Before You Launch",[2141,8484,8485],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":213,"searchDepth":214,"depth":214,"links":8487},[8488,8489,8490,8491,8492,8493,8494,8495],{"id":7792,"depth":217,"text":7793},{"id":7804,"depth":217,"text":7805},{"id":7837,"depth":217,"text":7838},{"id":8164,"depth":217,"text":8165},{"id":8200,"depth":217,"text":8201},{"id":8248,"depth":217,"text":8249},{"id":8438,"depth":217,"text":8439},{"id":182,"depth":217,"text":183},"Feature flags let you ship code continuously without releasing features continuously. Here's how to build a feature flag system that actually improves deployment safety.",[8498,8499],"feature flags","SaaS deployment",{},{"title":7761,"description":8496},"blog/saas-feature-flags",[8504,2573,8505],"Feature Flags","Deployment","KEzeT1s-J901BzDB7LupDQyp7WoRwL21XBinh_uv8lA",{"id":8508,"title":8509,"author":8510,"body":8511,"category":224,"date":225,"description":8802,"extension":227,"featured":228,"image":229,"keywords":8803,"meta":8806,"navigation":234,"path":8807,"readTime":236,"seo":8808,"stem":8809,"tags":8810,"__hash__":8813},"blog/blog/saas-metrics-to-track.md","SaaS Metrics That Actually Matter (And How to Track Them in Code)",{"name":9,"bio":10},{"type":12,"value":8512,"toc":8792},[8513,8517,8520,8523,8526,8528,8532,8538,8543,8557,8562,8594,8597,8603,8605,8609,8614,8617,8620,8626,8629,8635,8637,8641,8647,8650,8653,8656,8662,8664,8668,8673,8676,8679,8685,8691,8693,8697,8700,8706,8712,8722,8728,8730,8734,8740,8746,8752,8758,8760,8766,8768,8770],[15,8514,8516],{"id":8515},"metrics-that-tell-you-whats-actually-happening","Metrics That Tell You What's Actually Happening",[20,8518,8519],{},"SaaS founders often know the names of the metrics they should be tracking before they know what they mean or how to calculate them accurately. They pull a number from Stripe, call it MRR, and don't realize they've left out several categories of revenue — or included things that shouldn't count.",[20,8521,8522],{},"Bad metrics are worse than no metrics because they create confident misunderstanding. You make decisions based on numbers that don't reflect reality, and the feedback loop that should tell you something is wrong is broken.",[20,8524,8525],{},"Let me go through the metrics that actually matter, how to define them precisely, and what's needed technically to track them correctly.",[33,8527],{},[15,8529,8531],{"id":8530},"mrr-monthly-recurring-revenue","MRR: Monthly Recurring Revenue",[20,8533,8534,8537],{},[57,8535,8536],{},"The definition that matters:"," The sum of all normalized monthly recurring revenue from active subscriptions in the current period. \"Normalized\" means converting annual plans to monthly equivalents (annual plan ÷ 12) and excluding one-time payments, setup fees, and usage overages.",[20,8539,8540],{},[57,8541,8542],{},"Common mistakes:",[185,8544,8545,8548,8551,8554],{},[188,8546,8547],{},"Including one-time payments (not recurring, shouldn't count)",[188,8549,8550],{},"Not normalizing annual plans (you didn't earn all of it this month)",[188,8552,8553],{},"Including inactive (past-due) subscriptions (they haven't paid)",[188,8555,8556],{},"Including free trial accounts (no revenue to count)",[20,8558,8559],{},[57,8560,8561],{},"The MRR movements that matter:",[185,8563,8564,8570,8576,8582,8588],{},[188,8565,8566,8569],{},[57,8567,8568],{},"New MRR:"," Revenue from new customers this month",[188,8571,8572,8575],{},[57,8573,8574],{},"Expansion MRR:"," Additional revenue from existing customers (upgrades, seat adds)",[188,8577,8578,8581],{},[57,8579,8580],{},"Contraction MRR:"," Lost revenue from existing customers (downgrades, seat removals)",[188,8583,8584,8587],{},[57,8585,8586],{},"Churned MRR:"," Revenue lost from cancellations",[188,8589,8590,8593],{},[57,8591,8592],{},"Net new MRR:"," New + Expansion - Contraction - Churned",[20,8595,8596],{},"Tracking MRR movements, not just the total, tells you where your revenue growth (or loss) is coming from. Growing MRR from expansion is fundamentally healthier than growing MRR only from new customers, because it indicates existing customers are finding more value over time.",[20,8598,8599,8602],{},[57,8600,8601],{},"How to track it technically:"," Query your subscription records (not Stripe directly) filtered to active status. Join with plan records to get monthly price. Sum. For annual plans, divide annual amount by 12.",[33,8604],{},[15,8606,8608],{"id":8607},"churn-rate","Churn Rate",[20,8610,8611,8613],{},[57,8612,8536],{}," The percentage of paying customers (or MRR) lost in a given period.",[20,8615,8616],{},"Customer churn rate = (customers who cancelled in month) / (customers at start of month)",[20,8618,8619],{},"Revenue churn rate (also called gross revenue churn) = (MRR churned in month) / (MRR at start of month)",[20,8621,8622,8625],{},[57,8623,8624],{},"Net revenue churn"," = (MRR churned + contraction - expansion) / (MRR at start of month)",[20,8627,8628],{},"Negative net revenue churn — where expansion revenue from existing customers exceeds revenue lost from churn and contraction — is the signal that you've built a product with strong natural expansion. It means you can grow revenue without adding a single new customer, and that your existing customer base is becoming more valuable over time.",[20,8630,8631,8634],{},[57,8632,8633],{},"What's a good churn rate?"," For B2C SaaS, monthly churn under 5% is healthy. For SMB SaaS, under 3%. For enterprise SaaS, under 1% — enterprise customers should be sticky. High churn is always a product or customer-fit problem; discounting can delay it but won't solve it.",[33,8636],{},[15,8638,8640],{"id":8639},"ltv-customer-lifetime-value","LTV: Customer Lifetime Value",[20,8642,8643,8646],{},[57,8644,8645],{},"The definition:"," The average total revenue you'll receive from a customer over the lifetime of their relationship with you.",[20,8648,8649],{},"Simple version: LTV = ARPA × (1 / Monthly Churn Rate)",[20,8651,8652],{},"Where ARPA is average revenue per account per month.",[20,8654,8655],{},"If ARPA is $200/month and monthly churn is 2%, then LTV = $200 × (1/0.02) = $10,000.",[20,8657,8658,8661],{},[57,8659,8660],{},"The more useful version"," accounts for the cost to serve the customer (gross margin), because LTV as a revenue figure is less useful than LTV as a profit figure. Gross margin-adjusted LTV = LTV × Gross Margin %.",[33,8663],{},[15,8665,8667],{"id":8666},"cac-customer-acquisition-cost","CAC: Customer Acquisition Cost",[20,8669,8670,8672],{},[57,8671,8645],{}," The total cost of sales and marketing in a given period, divided by the number of new customers acquired in that period.",[20,8674,8675],{},"CAC = (Total Sales + Marketing Spend) / New Customers Acquired",[20,8677,8678],{},"Include everything: ad spend, sales salaries, marketing salaries, software for sales/marketing, event costs, agency fees. If you're excluding sales salaries because \"that's separate,\" your CAC is wrong.",[20,8680,8681,8684],{},[57,8682,8683],{},"The LTV:CAC ratio"," is the most important ratio in SaaS. A healthy B2B SaaS company has LTV:CAC of 3:1 or better. Below 3:1, you're not generating enough return to justify the acquisition spend. Above 5:1, you may be underinvesting in growth.",[20,8686,8687,8690],{},[57,8688,8689],{},"CAC Payback Period"," = CAC / ARPA. How many months does it take to recoup what you spent to acquire a customer? Under 12 months is healthy for most SaaS. Over 18 months creates cash flow problems, particularly for bootstrapped companies.",[33,8692],{},[15,8694,8696],{"id":8695},"the-metrics-dashboard-in-code","The Metrics Dashboard in Code",[20,8698,8699],{},"The right way to track these metrics is a combination of:",[20,8701,8702,8705],{},[57,8703,8704],{},"Event sourcing for billing events."," Every subscription state change — created, upgraded, downgraded, cancelled — should emit a structured event with a timestamp, customer ID, plan details, and amount. These events are the raw material for all your revenue metrics.",[20,8707,8708,8711],{},[57,8709,8710],{},"A subscription state table."," As described in the Stripe billing article — a mirror of Stripe subscription state in your own database, updated by webhooks.",[20,8713,8714,8717,8718,8721],{},[57,8715,8716],{},"A daily metrics snapshot job."," A scheduled process that runs once per day and calculates MRR, active subscribers, churn rate for the period, and expansion/contraction metrics, then stores the results in a ",[95,8719,8720],{},"metrics_snapshots"," table. This gives you a historical time series without recalculating everything from events every time a dashboard loads.",[20,8723,8724,8727],{},[57,8725,8726],{},"An analytics layer, not just Stripe."," Stripe's built-in analytics are useful but limited. You can't build custom cohort analyses, correlate subscription metrics with product usage, or segment by acquisition channel without your own analytics layer. Tools like Metabase or Redash against your own database often tell you more than Stripe's dashboards.",[33,8729],{},[15,8731,8733],{"id":8732},"the-vanity-metrics-worth-ignoring","The Vanity Metrics Worth Ignoring",[20,8735,8736,8739],{},[57,8737,8738],{},"Total signups."," Unless you're tracking the percentage of signups that activate and convert, total signups is a marketing ego metric. A product with 10,000 signups and a 2% conversion rate is worse than a product with 1,000 signups and a 20% conversion rate.",[20,8741,8742,8745],{},[57,8743,8744],{},"Total users."," Similar issue. Active users — specifically the ones who have used the core product feature in the last 30 days — are what matters. Stale accounts padded into a \"total users\" number is theater.",[20,8747,8748,8751],{},[57,8749,8750],{},"Page views and sessions."," Not SaaS metrics. Marketing metrics. Don't confuse them.",[20,8753,8754,8757],{},[57,8755,8756],{},"\"Revenue run rate\" calculated from a good month."," If you have an unusually good month and multiply by 12, that's not ARR. It's wishful thinking.",[33,8759],{},[20,8761,8762,8763,582],{},"The discipline of defining, tracking, and acting on the right metrics is what separates SaaS founders who build durable companies from ones who are perpetually surprised by what's happening in their business. If you're building the analytics layer for your SaaS and want to make sure you're tracking the right things, book a call at ",[171,8764,176],{"href":173,"rel":8765},[175],[33,8767],{},[15,8769,183],{"id":182},[185,8771,8772,8776,8780,8786],{},[188,8773,8774],{},[171,8775,211],{"href":210},[188,8777,8778],{},[171,8779,193],{"href":192},[188,8781,8782],{},[171,8783,8785],{"href":8784},"/blog/building-tech-business","Building a Tech Business Without Burning Out: What I've Learned",[188,8787,8788],{},[171,8789,8791],{"href":8790},"/blog/client-communication-developers","Client Communication for Developers: How to Build Trust While You Build Software",{"title":213,"searchDepth":214,"depth":214,"links":8793},[8794,8795,8796,8797,8798,8799,8800,8801],{"id":8515,"depth":217,"text":8516},{"id":8530,"depth":217,"text":8531},{"id":8607,"depth":217,"text":8608},{"id":8639,"depth":217,"text":8640},{"id":8666,"depth":217,"text":8667},{"id":8695,"depth":217,"text":8696},{"id":8732,"depth":217,"text":8733},{"id":182,"depth":217,"text":183},"MRR, churn, LTV, CAC — every SaaS founder knows the terms. Here's what they actually mean, how to calculate them correctly, and how to build them into your product.",[8804,8805],"SaaS metrics","MRR ARR churn",{},"/blog/saas-metrics-to-track",{"title":8509,"description":8802},"blog/saas-metrics-to-track",[2573,8811,8812],"Metrics","Analytics","2Ae1CrPCkV4foATAvV1wErGh-W386-2npW7j8Po9h_c",{"id":8815,"title":2543,"author":8816,"body":8817,"category":2154,"date":225,"description":9033,"extension":227,"featured":228,"image":229,"keywords":9034,"meta":9037,"navigation":234,"path":2542,"readTime":236,"seo":9038,"stem":9039,"tags":9040,"__hash__":9043},"blog/blog/saas-onboarding-best-practices.md",{"name":9,"bio":10},{"type":12,"value":8818,"toc":9024},[8819,8823,8826,8829,8831,8835,8838,8841,8844,8847,8850,8852,8856,8866,8869,8875,8881,8887,8889,8893,8899,8905,8911,8917,8919,8923,8926,8929,8935,8945,8955,8961,8963,8967,8970,8976,8982,8988,8994,8996,9002,9004,9006],[15,8820,8822],{"id":8821},"the-most-expensive-moment-in-your-saas-product","The Most Expensive Moment in Your SaaS Product",[20,8824,8825],{},"The most expensive user interaction in a SaaS product is the first one. This is when the user has the highest intent, the lowest skepticism, and the most motivation to make the product work for them. It's also the moment most SaaS products waste by presenting a blank screen, an overwhelmed form, or a setup wizard that asks ten questions before showing any value.",[20,8827,8828],{},"Activation rate — the percentage of new signups who reach a meaningful usage milestone — is the SaaS metric that most directly predicts long-term retention. A user who activates is five times more likely to still be paying in six months than a user who doesn't. And activation is almost entirely determined by the first 15 minutes of experience.",[33,8830],{},[15,8832,8834],{"id":8833},"defining-your-activation-milestone","Defining Your Activation Milestone",[20,8836,8837],{},"Before you can optimize onboarding, you need to know what \"activated\" means for your product. This is a technical measurement decision as much as a product decision.",[20,8839,8840],{},"The activation milestone is the action that correlates most strongly with long-term retention. It's not \"created an account\" — that's registration, not activation. It's the first moment when the user experiences the core value your product delivers.",[20,8842,8843],{},"For a project management tool: created their first project and added at least one task.\nFor an analytics product: successfully installed the tracking snippet and received at least one event.\nFor a team communication tool: sent a message in a channel that at least one other person read.",[20,8845,8846],{},"To identify your activation milestone, run a cohort analysis: what action, taken in the first week, most strongly predicts that a user is still active at day 30? That's your activation event.",[20,8848,8849],{},"Once you know the event, instrument it, track it by cohort, and treat improving its conversion rate as a primary product priority.",[33,8851],{},[15,8853,8855],{"id":8854},"the-technical-architecture-of-onboarding","The Technical Architecture of Onboarding",[20,8857,8858,8861,8862,8865],{},[57,8859,8860],{},"User journey state machine."," Onboarding is a flow, not a single screen. Each user is at some step of the flow at any given moment. Model this explicitly in your data layer — an ",[95,8863,8864],{},"onboarding_step"," column on the user or organization record, or a more sophisticated progress model if your onboarding branches.",[20,8867,8868],{},"This state model lets you: resume where the user left off if they close the browser, send contextual email nudges based on where they got stuck, and segment users in your analytics by onboarding stage.",[20,8870,8871,8874],{},[57,8872,8873],{},"Progress persistence across sessions."," Users rarely complete onboarding in one sitting. When they come back the next day, they should see exactly where they left off, not start over. This requires server-side state persistence, not just browser local storage. Every step the user completes should be persisted immediately.",[20,8876,8877,8880],{},[57,8878,8879],{},"Conditional onboarding paths."," Different user segments need different onboarding flows. A solo founder and an enterprise IT admin signing up for the same product have fundamentally different needs, different amounts of time, and different definitions of \"ready to work.\" Ask one or two qualifying questions at signup (role, team size, use case) and branch the flow accordingly.",[20,8882,8883,8886],{},[57,8884,8885],{},"Staged data collection."," Resist the temptation to collect all user information upfront. Get what you need to deliver the first value moment. Collect additional profile information after the user has experienced the product and is motivated to configure it further. Every additional field on the signup form reduces completion rate.",[33,8888],{},[15,8890,8892],{"id":8891},"the-ux-patterns-that-drive-activation","The UX Patterns That Drive Activation",[20,8894,8895,8898],{},[57,8896,8897],{},"Interactive walkthroughs over passive tours."," A tooltip-based product tour that pops up and explains features is passive — the user reads, maybe, and then dismisses it. An interactive walkthrough that guides the user to complete an action (\"click here to create your first project\") is active. Active onboarding produces higher activation rates because the user actually does the thing rather than learning about doing the thing.",[20,8900,8901,8904],{},[57,8902,8903],{},"Sample data that demonstrates value."," A blank canvas is one of the highest-friction moments in product onboarding. Show the user what the product looks like when it's working by pre-populating it with realistic sample data. Let them experience the value before they do any work to create it. Offer a \"start from scratch\" option for users who don't want the sample, but make the sample the default.",[20,8906,8907,8910],{},[57,8908,8909],{},"The \"aha moment\" as early as possible."," Every SaaS product has a moment when the user first understands what the product does for them — when the abstract value proposition becomes concrete. Design your onboarding to reach that moment as quickly as possible. Everything before it is friction. Everything after it is retention.",[20,8912,8913,8916],{},[57,8914,8915],{},"Progress indicators for long flows."," If your onboarding legitimately requires multiple steps (setup of integrations, configuration of settings, data import), show a progress indicator. \"Step 3 of 5\" tells the user they're not in an infinite setup tunnel and motivates completion.",[33,8918],{},[15,8920,8922],{"id":8921},"email-onboarding-as-a-parallel-channel","Email Onboarding as a Parallel Channel",[20,8924,8925],{},"In-product onboarding captures users who are actively engaged. Email onboarding captures users who signed up, got distracted, and need a reason to come back.",[20,8927,8928],{},"The sequences that work:",[20,8930,8931,8934],{},[57,8932,8933],{},"Immediate welcome email."," Send within 60 seconds of signup. Include a single, clear CTA that returns the user to the specific step they should do next. Not a list of features. One action.",[20,8936,8937,8940,8941,8944],{},[57,8938,8939],{},"Day 2 nudge."," If the user hasn't reached your activation milestone by day 2, send a check-in email. \"You're close to getting ",[272,8942,8943],{},"specific value",". Here's the one thing you need to do.\" Include a direct link that bypasses the homepage and goes to the specific step.",[20,8946,8947,8950,8951,8954],{},[57,8948,8949],{},"Day 7 re-engagement."," If the user still hasn't activated by day 7, the message changes. \"We noticed you haven't ",[272,8952,8953],{},"done the thing",". Is there something specific that wasn't working?\" This can be a direct reply, which generates conversation with churning users and often reveals friction you didn't know about.",[20,8956,8957,8960],{},[57,8958,8959],{},"Day 14 final check-in."," For users who've logged in but haven't activated: a case study or testimonial from a similar user showing the value they got. For users who've never logged in after signup: a direct offer to help, personalized if you have the information.",[33,8962],{},[15,8964,8966],{"id":8965},"instrumenting-onboarding-to-improve-it","Instrumenting Onboarding to Improve It",[20,8968,8969],{},"You can't improve what you don't measure. The instruments you need:",[20,8971,8972,8975],{},[57,8973,8974],{},"Funnel analysis by step."," Where are users dropping off in the onboarding flow? The step with the highest drop rate is your highest-priority optimization target.",[20,8977,8978,8981],{},[57,8979,8980],{},"Time-to-activation by cohort."," Are users taking longer to activate over time? That might mean your product is getting more complex, or your signup traffic is becoming less qualified.",[20,8983,8984,8987],{},[57,8985,8986],{},"Activation rate by acquisition channel."," Users who come from different channels (organic search, paid ads, referral, product hunt) often have different activation rates. This tells you which acquisition channels bring users who are a good fit for the product.",[20,8989,8990,8993],{},[57,8991,8992],{},"Support ticket themes in the first week."," The questions new users ask most often are the things your onboarding isn't answering. Review first-week support tickets monthly and update onboarding to proactively address them.",[33,8995],{},[20,8997,8998,8999,582],{},"Good onboarding is the result of deliberate engineering and product decisions, not something that happens by accident. If you're building a SaaS product and want to think through how to design an onboarding flow that actually activates users, book a call at ",[171,9000,176],{"href":173,"rel":9001},[175],[33,9003],{},[15,9005,183],{"id":182},[185,9007,9008,9012,9016,9020],{},[188,9009,9010],{},[171,9011,7761],{"href":7760},[188,9013,9014],{},[171,9015,2170],{"href":2568},[188,9017,9018],{},[171,9019,2531],{"href":2530},[188,9021,9022],{},[171,9023,8482],{"href":8481},{"title":213,"searchDepth":214,"depth":214,"links":9025},[9026,9027,9028,9029,9030,9031,9032],{"id":8821,"depth":217,"text":8822},{"id":8833,"depth":217,"text":8834},{"id":8854,"depth":217,"text":8855},{"id":8891,"depth":217,"text":8892},{"id":8921,"depth":217,"text":8922},{"id":8965,"depth":217,"text":8966},{"id":182,"depth":217,"text":183},"Onboarding is where most SaaS products lose new users. Here's how the technical architecture and UX decisions behind onboarding determine whether users activate or churn.",[9035,9036],"SaaS onboarding","SaaS activation",{},{"title":2543,"description":9033},"blog/saas-onboarding-best-practices",[2573,9041,9042],"Onboarding","User Experience","J8h2pJuasdzx8xwnEROb3l_S_NtZX_HZLwaKpyFcx6U",[9045,9047,9048,9049,9050,9051,9052,9053,9054,9055,9056,9057,9058,9059,9060,9061,9062,9063,9064,9065,9066,9067,9068,9069,9070,9071,9072,9073,9074,9075,9076,9077,9078,9079,9080,9081,9082,9083,9084,9086,9087,9088,9089,9090,9091,9092,9093,9094,9095,9096,9097,9098,9099,9100,9101,9102,9103,9104,9105,9106,9107,9108,9109,9110,9111,9112,9113,9114,9115,9116,9117,9118,9119,9120,9121,9122,9123,9124,9125,9126,9127,9128,9130,9131,9132,9133,9134,9135,9136,9137,9138,9139,9140,9141,9142,9143,9144,9145,9146,9147,9148,9149,9150,9151,9152,9153,9154,9155,9156,9157,9158,9159,9160,9161,9162,9163,9164,9165,9166,9167,9168,9169,9170,9171,9172,9173,9174,9175,9176,9177,9178,9179,9180,9181,9182,9183,9184,9185,9186,9187,9188,9189,9190,9191,9192,9193,9194,9195,9196,9197,9198,9199,9200,9201,9202,9203,9204,9205,9206,9207,9208,9209,9210,9211,9212,9213,9214,9215,9216,9217,9218,9219,9220,9221,9222,9223,9224,9225,9226,9227,9228,9229,9230,9231,9232,9233,9234,9235,9236,9237,9238,9239,9240,9241,9242,9243,9244,9245,9246,9247,9248,9249,9250,9251,9252,9253,9254,9255,9256,9257,9258,9259,9260,9261,9262,9263,9264,9265,9266,9267,9268,9269,9270,9271,9272,9273,9274,9275,9276,9277,9278,9279,9280,9281,9282,9283,9284,9285,9286,9287,9288,9289,9290,9291,9292,9293,9294,9295,9296,9297,9298,9299,9300,9301,9302,9303,9304,9305,9306,9307,9308,9309,9310,9311,9312,9313,9314,9315,9316,9317,9318,9319,9320,9321,9322,9323,9324,9325,9326,9327,9328,9329,9330,9331,9332,9333,9334,9335,9336,9337,9338,9339,9340,9341,9342,9343,9344,9345,9346,9347,9348,9349,9350,9351,9352,9353,9354,9355,9356,9357,9358,9359,9360,9361,9362,9363,9364,9365,9366,9367,9368,9369,9370,9371,9372,9373,9374,9375,9376,9377,9378,9379,9380,9381,9382,9383,9384,9385,9386,9387,9388,9389,9390,9391,9392,9393,9394,9395,9396,9397,9398,9399,9400,9401,9402,9403,9404,9405,9406,9407,9408,9409,9410,9411,9412,9413,9414,9415,9416,9417,9418,9419,9420,9421,9422,9423,9424,9425,9426,9427,9428,9429,9430,9431,9432,9433,9434,9435,9436,9437,9438,9439,9440,9441,9442,9443,9444,9445,9446,9447,9448,9449,9450,9451,9452,9453,9454,9455,9456,9457,9458,9459,9460,9461,9462,9463,9464,9465,9466,9467,9468,9469,9470,9471,9472,9473,9474,9475,9476,9477,9478,9479,9480,9481,9482,9483,9484,9485,9486,9487,9488,9489,9490,9491,9492,9493,9494,9495,9496,9497,9498,9499,9500,9501,9502,9503,9504,9505,9506,9507,9508,9509,9510,9511,9512,9513,9514,9515,9516,9517,9518,9520,9521,9522,9523,9524,9525,9526,9527,9528,9529,9530,9531,9532,9533,9534,9535,9536,9537,9538,9539,9540,9541,9542,9543,9544,9545,9546,9547,9548,9549,9550,9551,9552,9553,9554,9555,9556,9557,9558,9559,9560,9561,9562,9563,9564,9565,9566,9567,9568,9569,9570,9571,9572,9573,9574,9575,9576,9577,9578,9579,9580,9581,9582,9583,9584,9585,9586,9587,9588,9589,9590,9591,9592,9593,9594,9595,9596,9597,9598,9599,9600,9601,9602,9603,9604,9605,9606,9607,9608,9609,9610,9611,9612,9613,9614,9615,9616,9617,9618,9619,9620,9621,9622,9623,9624,9625,9626,9627,9628,9629,9630,9631,9632,9633,9634,9635,9636,9637,9638,9639,9640,9641,9642,9643,9644,9645,9646,9647,9648,9649,9650,9651,9652,9653,9654,9655,9656,9657,9658,9659,9660,9661,9662,9663,9664,9665,9666,9667,9668,9669,9670,9671,9672,9673,9674,9675,9676,9677,9678,9679,9680,9681,9682,9683,9684,9685,9686,9687,9688],{"category":9046},"Frontend",{"category":3857},{"category":3313},{"category":2154},{"category":224},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3313},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":6801},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":9085},"Security",{"category":9085},{"category":224},{"category":224},{"category":3857},{"category":9085},{"category":3857},{"category":6801},{"category":9085},{"category":2154},{"category":224},{"category":3054},{"category":3313},{"category":3857},{"category":2154},{"category":6801},{"category":2154},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3054},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":2154},{"category":9129},"Career",{"category":3313},{"category":3313},{"category":224},{"category":6801},{"category":224},{"category":2154},{"category":2154},{"category":224},{"category":2154},{"category":6801},{"category":2154},{"category":3054},{"category":3054},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3313},{"category":6801},{"category":224},{"category":3054},{"category":3054},{"category":3054},{"category":3857},{"category":2154},{"category":2154},{"category":3857},{"category":9046},{"category":3313},{"category":3054},{"category":3054},{"category":9085},{"category":3054},{"category":224},{"category":3313},{"category":3857},{"category":2154},{"category":3857},{"category":6801},{"category":3857},{"category":6801},{"category":9085},{"category":3857},{"category":3857},{"category":2154},{"category":224},{"category":2154},{"category":9046},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":224},{"category":224},{"category":3857},{"category":9046},{"category":9085},{"category":6801},{"category":9085},{"category":9046},{"category":2154},{"category":2154},{"category":3054},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":3054},{"category":2154},{"category":2154},{"category":3857},{"category":3857},{"category":9085},{"category":6801},{"category":6801},{"category":9129},{"category":9129},{"category":9129},{"category":224},{"category":2154},{"category":3054},{"category":6801},{"category":3857},{"category":3857},{"category":3054},{"category":6801},{"category":6801},{"category":9046},{"category":2154},{"category":3857},{"category":3857},{"category":2154},{"category":3857},{"category":3054},{"category":3054},{"category":3857},{"category":9085},{"category":3857},{"category":6801},{"category":9085},{"category":6801},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":9085},{"category":2154},{"category":3054},{"category":3054},{"category":224},{"category":2154},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":6801},{"category":6801},{"category":6801},{"category":2154},{"category":3857},{"category":3857},{"category":3857},{"category":3054},{"category":224},{"category":3857},{"category":3857},{"category":2154},{"category":3857},{"category":2154},{"category":9046},{"category":3857},{"category":224},{"category":224},{"category":2154},{"category":2154},{"category":3313},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":2154},{"category":3054},{"category":3054},{"category":3054},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":3857},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":224},{"category":224},{"category":3857},{"category":2154},{"category":9046},{"category":6801},{"category":9129},{"category":3857},{"category":3857},{"category":9085},{"category":2154},{"category":3857},{"category":3857},{"category":3054},{"category":3857},{"category":9046},{"category":3054},{"category":3054},{"category":9085},{"category":2154},{"category":2154},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":9129},{"category":3857},{"category":6801},{"category":2154},{"category":2154},{"category":3857},{"category":3054},{"category":3857},{"category":3857},{"category":3857},{"category":9046},{"category":3857},{"category":3857},{"category":2154},{"category":3857},{"category":2154},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":3313},{"category":3313},{"category":2154},{"category":3857},{"category":3054},{"category":3054},{"category":3857},{"category":2154},{"category":3857},{"category":3857},{"category":3313},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":2154},{"category":9085},{"category":2154},{"category":2154},{"category":9046},{"category":2154},{"category":9046},{"category":9046},{"category":9085},{"category":6801},{"category":2154},{"category":6801},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":2154},{"category":224},{"category":2154},{"category":2154},{"category":3857},{"category":6801},{"category":3313},{"category":3313},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":224},{"category":2154},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":9046},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":2154},{"category":6801},{"category":3857},{"category":224},{"category":3313},{"category":3857},{"category":224},{"category":9085},{"category":3857},{"category":9085},{"category":2154},{"category":3054},{"category":3857},{"category":3857},{"category":2154},{"category":3857},{"category":6801},{"category":3857},{"category":3857},{"category":2154},{"category":224},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":224},{"category":2154},{"category":2154},{"category":224},{"category":3054},{"category":2154},{"category":3313},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":3857},{"category":3857},{"category":3857},{"category":3313},{"category":2154},{"category":2154},{"category":6801},{"category":9046},{"category":2154},{"category":3857},{"category":2154},{"category":6801},{"category":224},{"category":224},{"category":9046},{"category":9046},{"category":3857},{"category":224},{"category":9085},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":2154},{"category":2154},{"category":6801},{"category":2154},{"category":2154},{"category":2154},{"category":9519},"Programming",{"category":2154},{"category":2154},{"category":6801},{"category":6801},{"category":2154},{"category":2154},{"category":224},{"category":9085},{"category":2154},{"category":224},{"category":2154},{"category":2154},{"category":2154},{"category":2154},{"category":3054},{"category":6801},{"category":224},{"category":224},{"category":2154},{"category":2154},{"category":224},{"category":2154},{"category":9085},{"category":224},{"category":2154},{"category":2154},{"category":6801},{"category":6801},{"category":3857},{"category":224},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":9046},{"category":3857},{"category":3054},{"category":9085},{"category":9085},{"category":9085},{"category":9085},{"category":9085},{"category":9085},{"category":3857},{"category":2154},{"category":3054},{"category":6801},{"category":3054},{"category":6801},{"category":2154},{"category":9046},{"category":3857},{"category":6801},{"category":9046},{"category":3857},{"category":3857},{"category":3857},{"category":6801},{"category":6801},{"category":6801},{"category":224},{"category":224},{"category":224},{"category":6801},{"category":6801},{"category":224},{"category":224},{"category":224},{"category":3857},{"category":9085},{"category":2154},{"category":3054},{"category":2154},{"category":3857},{"category":224},{"category":224},{"category":3857},{"category":3857},{"category":6801},{"category":2154},{"category":6801},{"category":6801},{"category":6801},{"category":9046},{"category":2154},{"category":3857},{"category":3857},{"category":224},{"category":224},{"category":6801},{"category":2154},{"category":9129},{"category":6801},{"category":9129},{"category":224},{"category":3857},{"category":6801},{"category":3857},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":3857},{"category":3313},{"category":3313},{"category":3054},{"category":3857},{"category":3857},{"category":3857},{"category":3857},{"category":2154},{"category":2154},{"category":9046},{"category":2154},{"category":9085},{"category":6801},{"category":9046},{"category":9046},{"category":2154},{"category":2154},{"category":9046},{"category":9046},{"category":9046},{"category":9085},{"category":2154},{"category":2154},{"category":224},{"category":2154},{"category":6801},{"category":3857},{"category":3857},{"category":6801},{"category":3857},{"category":3857},{"category":6801},{"category":3857},{"category":2154},{"category":3857},{"category":9085},{"category":3857},{"category":3857},{"category":3857},{"category":3054},{"category":3054},{"category":9085},1772951194537]