[{"data":1,"prerenderedAt":4803},["ShallowReactive",2],{"blog-paginated-count":3,"blog-paginated-22":4,"blog-paginated-cats":4158},640,[5,139,275,934,1152,1466,1621,2107,2356,3241,3348,3493,3646,3840,3968],{"id":6,"title":7,"author":8,"body":11,"category":113,"date":114,"description":115,"extension":116,"featured":117,"image":118,"keywords":119,"meta":126,"navigation":127,"path":128,"readTime":129,"seo":130,"stem":131,"tags":132,"__hash__":138},"blog/blog/boudicca-celtic-resistance.md","Boudicca: Celtic Queen Against Roman Empire",{"name":9,"bio":10},"James Ross Jr.","Strategic Systems Architect & Enterprise Software Developer",{"type":12,"value":13,"toc":104},"minimark",[14,19,23,32,35,38,42,45,48,51,54,57,61,64,67,70,73,77,80,83,86],[15,16,18],"h2",{"id":17},"the-provocation","The Provocation",[20,21,22],"p",{},"The revolt of Boudicca in AD 60-61 was the most serious challenge to Roman authority in Britain and one of the most destructive rebellions in the entire history of the Roman Empire. Three cities were burned to the ground, tens of thousands of people were killed, and for a brief period it appeared that the Romans might lose the province entirely. The rebellion was not random. It was provoked by specific acts of Roman brutality that turned a compliant client kingdom into an existential threat.",[20,24,25,26,31],{},"Boudicca was queen of the Iceni, a ",[27,28,30],"a",{"href":29},"/blog/celtic-britain-before-romans","Celtic British tribe"," occupying what is now Norfolk and parts of Suffolk in eastern England. Her husband, Prasutagus, had ruled as a Roman client king -- nominally independent but effectively subordinate to Rome. When Prasutagus died around AD 60, he left his kingdom jointly to his two daughters and the Roman emperor Nero, a common arrangement among client rulers hoping to secure their family's position.",[20,33,34],{},"Rome had other plans. The procurator Catus Decianus seized the entire kingdom, treating it as conquered territory rather than a bequest. Iceni nobles were stripped of their estates. Roman financiers, including the philosopher Seneca, called in loans they had extended to British aristocrats. And in an act of calculated humiliation, Roman soldiers publicly flogged Boudicca and assaulted her daughters.",[20,36,37],{},"The consequences of this provocation were catastrophic -- for Rome.",[15,39,41],{"id":40},"the-destruction","The Destruction",[20,43,44],{},"Boudicca rallied the Iceni and their neighbors the Trinovantes, whose own grievances against Rome were substantial. The veterans' colony at Camulodunum (Colchester), built on confiscated Trinovantian land, was a constant reminder of dispossession. The hated Temple of Claudius, built with forced local labor and funded by compulsory contributions, symbolized everything the native population resented about Roman rule.",[20,46,47],{},"Camulodunum was the first target. The colony had no walls -- a testament to Roman arrogance about their security in Britain -- and the garrison was minimal because the main Roman army under the governor Suetonius Paulinus was on the far side of the province, destroying the druidic center on Anglesey. The veteran colonists and their families took refuge in the Temple of Claudius, which held out for two days before being overwhelmed. The entire settlement was destroyed. Archaeological evidence reveals a thick layer of burned debris -- the \"Boudiccan destruction layer\" -- that is still visible in modern excavations at Colchester.",[20,49,50],{},"A detachment of the Ninth Legion, marching south to relieve Camulodunum, was ambushed and its infantry annihilated. Only the cavalry escaped.",[20,52,53],{},"Boudicca's army then turned on Londinium (London), which Suetonius had reached first but could not defend with his available forces. He made the cold decision to abandon the city. Londinium was burned. The destruction layer found in London archaeological sites -- a layer of reddened, fire-damaged earth -- corresponds precisely to the period of the revolt. Verulamium (St Albans) followed. Three of the most important settlements in Roman Britain were reduced to ashes within weeks.",[20,55,56],{},"Cassius Dio estimated that 70,000 to 80,000 people died in the three destructions. Even if the figure is inflated, the scale of killing was enormous.",[15,58,60],{"id":59},"the-battle","The Battle",[20,62,63],{},"Suetonius Paulinus, one of Rome's most experienced military commanders, gathered his available forces -- elements of the Fourteenth and Twentieth Legions and associated auxiliaries, perhaps 10,000 men -- and chose his ground carefully. The exact location of the final battle is unknown, though several sites in the West Midlands have been proposed.",[20,65,66],{},"Suetonius positioned his forces in a narrow defile with forest behind them, preventing encirclement and negating the Britons' numerical advantage. Boudicca's army, which ancient sources claim numbered over 100,000 (probably an exaggeration, but the force was certainly very large), attacked directly into the Roman position.",[20,68,69],{},"The result was a Roman tactical masterpiece. The disciplined legionary formation absorbed the initial charge, then advanced in a wedge that compressed the British force in the narrow space. The Britons' own wagons, drawn up behind their army to serve as viewing platforms for families watching the battle, became a trap when the retreat began. Roman cavalry sealed the flanks. The slaughter was immense. Tacitus claims 80,000 Britons died, against 400 Roman casualties -- numbers that are certainly distorted but reflect a decisive Roman victory.",[20,71,72],{},"Boudicca died shortly after the battle. Tacitus says she took poison. Cassius Dio says she fell ill. Her burial place is unknown.",[15,74,76],{"id":75},"the-aftermath-and-the-legacy","The Aftermath and the Legacy",[20,78,79],{},"The revolt transformed Roman policy in Britain. The harsh administration that had provoked the rebellion was replaced by more conciliatory governance under a new procurator, Julius Classicianus, whose tombstone was found in London. The lesson was clear: push a Celtic population too far, and the response could be existential.",[20,81,82],{},"Boudicca became a symbol of resistance that far outlasted the Roman period. In the Victorian era, she was reimagined as a British national heroine, and a bronze statue of her riding a chariot stands on the Thames Embankment opposite the Houses of Parliament. The irony of a Celtic queen who fought against imperial occupation being claimed as a symbol of the British Empire was apparently lost on the Victorians.",[20,84,85],{},"For Celtic heritage, Boudicca represents something more specific. She was a woman wielding military and political authority in a Celtic society that, while not egalitarian by modern standards, afforded women a degree of power and agency that Roman society found deeply alien. Tacitus put a speech in her mouth that captured the contrast: \"It is not as a woman descended from noble ancestry, but as one of the people that I am avenging lost freedom.\"",[20,87,88,89,93,94,98,99,103],{},"Her revolt is a reminder that Celtic resistance to Rome was not merely military. It was a clash of social systems -- the hierarchical, centralized authority of Rome against the tribal, kinship-based societies of the ",[27,90,92],{"href":91},"/blog/la-tene-celtic-civilization","Celtic world",". The same spirit of defiance would echo centuries later in the ",[27,95,97],{"href":96},"/blog/celtiberians-spain","Celtiberian resistance at Numantia"," and in the ",[27,100,102],{"href":101},"/blog/highland-clearances-clan-ross-diaspora","Highland clearances"," that scattered Celtic peoples across the globe. Rome won that clash in Britain, but never completely. The Celtic substrate survived beneath the Roman surface, and when Rome withdrew, it re-emerged.",{"title":105,"searchDepth":106,"depth":106,"links":107},"",3,[108,110,111,112],{"id":17,"depth":109,"text":18},2,{"id":40,"depth":109,"text":41},{"id":59,"depth":109,"text":60},{"id":75,"depth":109,"text":76},"Heritage","2026-01-01","In AD 60, Boudicca of the Iceni led the most devastating revolt in the history of Roman Britain, burning three cities and killing tens of thousands. Her rebellion remains one of the defining moments of Celtic resistance against imperial power.","md",false,null,[120,121,122,123,124,125],"boudicca celtic queen","boudicca revolt","iceni rebellion","celtic resistance rome","boudicca roman britain","boudica history",{},true,"/blog/boudicca-celtic-resistance",9,{"title":7,"description":115},"blog/boudicca-celtic-resistance",[133,134,135,136,137],"Boudicca","Celtic Resistance","Roman Britain","Iceni","Celtic History","aiKgYVCiO-rU1Fz80Y-xWz8PkpB5IHA3LyNVILMbKiw",{"id":140,"title":141,"author":142,"body":143,"category":113,"date":114,"description":259,"extension":116,"featured":117,"image":118,"keywords":260,"meta":264,"navigation":127,"path":265,"readTime":266,"seo":267,"stem":268,"tags":269,"__hash__":274},"blog/blog/tuatha-de-danann-mythology.md","The Tuatha De Danann: Gods, Magic, and Memory",{"name":9,"bio":10},{"type":12,"value":144,"toc":253},[145,149,162,165,168,172,175,182,188,194,200,208,212,215,218,221,225,238],[15,146,148],{"id":147},"the-people-of-the-goddess-danu","The People of the Goddess Danu",[20,150,151,152,156,157,161],{},"The Tuatha De Danann — the \"People of the Goddess Danu\" — are the divine race of ",[27,153,155],{"href":154},"/blog/ancient-irish-mythology","Irish mythology",". According to the ",[27,158,160],{"href":159},"/blog/lebor-gabala-erenn-book-of-invasions","Lebor Gabala Erenn",", they were the fifth wave of settlers to reach Ireland, arriving from the northern islands of the world where they had learned druidry, magic, prophecy, and skill in battle. They came in dark clouds, landing on the mountains of Conmaicne Rein in Connacht, and their arrival was preceded by three days of darkness over the land.",[20,163,164],{},"The Tuatha De Danann were not ordinary settlers. They were gods — or, more precisely, they occupy the same narrative space that gods occupy in other Indo-European mythologies. Like the Norse Aesir or the Greek Olympians, they are a divine race with individual personalities, domains of power, and complex relationships. But unlike the gods of Olympus, the Tuatha De Danann are presented in the Irish sources as historical figures — supernatural, certainly, but inhabiting the same chronological framework as later, mortal rulers.",[20,166,167],{},"This historicizing tendency is partly the work of the Christian monks who recorded the myths. Uncomfortable with pagan gods, they recast the Tuatha De Danann as powerful but mortal ancestors, stripping them of explicit divinity while preserving their supernatural attributes. The result is a uniquely Irish treatment of divine mythology — gods who are also characters in a pseudo-historical narrative.",[15,169,171],{"id":170},"the-four-treasures","The Four Treasures",[20,173,174],{},"The Tuatha De Danann brought four magical treasures to Ireland, each associated with one of their four great cities:",[20,176,177,181],{},[178,179,180],"strong",{},"The Stone of Fal"," (from Falias) — a stone that cried out when the rightful king of Ireland stood upon it. It was placed at Tara, the seat of the High Kings, and its cry validated legitimate sovereignty.",[20,183,184,187],{},[178,185,186],{},"The Spear of Lugh"," (from Gorias) — a spear that never missed its mark, wielded by Lugh Lamhfhada (Lugh of the Long Arm), the greatest warrior and craftsman of the Tuatha De Danann.",[20,189,190,193],{},[178,191,192],{},"The Sword of Nuada"," (from Findias) — from which no one could escape once it was drawn.",[20,195,196,199],{},[178,197,198],{},"The Cauldron of the Dagda"," (from Murias) — a cauldron from which no company ever went unsatisfied, an inexhaustible source of nourishment.",[20,201,202,203,207],{},"These treasures map onto the four classical elements (earth, fire, air, water) and the four cardinal directions. They also parallel the regalia of sovereignty found across Indo-European cultures — the stone of coronation, the weapon of legitimate force, and the vessel of plenty. The parallels are not accidental. They reflect the deep Indo-European heritage that the Gaels shared with their distant cousins across the continent, a heritage that ",[27,204,206],{"href":205},"/blog/r1b-l21-atlantic-celtic-haplogroup","Y-DNA research"," has confirmed through the genetic links between Celtic, Germanic, and Italic populations.",[15,209,211],{"id":210},"the-battles-for-ireland","The Battles for Ireland",[20,213,214],{},"The Tuatha De Danann's claim to Ireland was established through two great battles. The First Battle of Mag Tuired was fought against the Fir Bolg — a previous wave of settlers — and resulted in the Tuatha De Danann's conquest of the island. King Nuada lost his arm in the fighting and was replaced as king because, under Irish law, a king had to be physically unblemished.",[20,216,217],{},"The Second Battle of Mag Tuired was fought against the Fomorians — a race of sea-dwelling beings who represent chaos, darkness, and the destructive forces of nature. This battle is the central myth of the Tuatha De Danann cycle. Lugh, the young champion who combined the skills of all the other gods in one person, led the Tuatha De Danann to victory, slaying the Fomorian king Balor of the Evil Eye with a slingstone to the eye.",[20,219,220],{},"The victory over the Fomorians established cosmic order — the triumph of civilization, skill, and legitimate sovereignty over primordial chaos. It is Ireland's version of the universal mythological pattern (paralleled in the Norse Aesir vs. Jotnar, the Greek Olympians vs. Titans) in which a younger, more civilized race of gods defeats an older, more chaotic one.",[15,222,224],{"id":223},"retreat-into-the-mounds","Retreat into the Mounds",[20,226,227,228,232,233,237],{},"The Tuatha De Danann's supremacy ended with the arrival of the ",[27,229,231],{"href":230},"/blog/sons-of-mil-milesian-invasion-ireland","Sons of Mil"," — the Gaels, the ancestors of the historical Irish. Defeated by the Milesians, the Tuatha De Danann did not die or leave Ireland. Instead, the Dagda assigned each of them a ",[234,235,236],"em",{},"sidh"," — an underground dwelling, typically identified with the Neolithic passage tombs and barrow mounds that dot the Irish landscape.",[20,239,240,241,244,245,248,249,252],{},"This is the origin of the ",[234,242,243],{},"aos sidhe"," — the fairy folk of later Irish tradition. The gods did not disappear. They went underground, becoming the fairies, the ",[234,246,247],{},"good people",", the ",[234,250,251],{},"daoine sidhe"," who inhabit a parallel world just beneath the surface of the visible one. Every fairy fort in Ireland, every mound that farmers carefully avoid plowing, is a memory of the Tuatha De Danann — divine beings who ruled Ireland before the Gaels and who never entirely left.",{"title":105,"searchDepth":106,"depth":106,"links":254},[255,256,257,258],{"id":147,"depth":109,"text":148},{"id":170,"depth":109,"text":171},{"id":210,"depth":109,"text":211},{"id":223,"depth":109,"text":224},"The Tuatha De Danann were Ireland's divine race — masters of art, war, and magic who retreated into the fairy mounds when the Gaels arrived.",[261,262,263],"tuatha de danann","tuatha de danann mythology","irish gods mythology",{},"/blog/tuatha-de-danann-mythology",5,{"title":141,"description":259},"blog/tuatha-de-danann-mythology",[270,271,272,273],"Tuatha De Danann","Irish Mythology","Celtic Gods","Ancient Ireland","byWWtdYX86HiyWG24f_kojkV6vS1UTywe1d4ZYYm0FQ",{"id":276,"title":277,"author":278,"body":279,"category":920,"date":921,"description":922,"extension":116,"featured":117,"image":118,"keywords":923,"meta":926,"navigation":127,"path":927,"readTime":369,"seo":928,"stem":929,"tags":930,"__hash__":933},"blog/blog/canary-deployment-strategy.md","Canary Deployments: Testing in Production Safely",{"name":9,"bio":10},{"type":12,"value":280,"toc":914},[281,284,287,291,294,297,474,477,480,483,487,490,496,502,508,688,691,699,703,706,709,731,734,830,833,837,840,843,891,894,902,910],[20,282,283],{},"Canary deployment is named after the canary in the coal mine — you send a small portion of traffic to the new version and watch closely for problems before exposing all users. If the canary is healthy, you gradually increase traffic. If it shows signs of trouble, you pull it back. The entire production user base is never exposed to an untested release.",[20,285,286],{},"This is the most sophisticated deployment strategy in common use, and it catches problems that no staging environment can replicate — performance under real load, edge cases from real user behavior, and integration issues with real third-party services.",[15,288,290],{"id":289},"traffic-splitting-architecture","Traffic Splitting Architecture",[20,292,293],{},"Canary deployment requires a traffic splitting mechanism that can route a configurable percentage of requests to the new version. The implementation depends on your infrastructure.",[20,295,296],{},"In Kubernetes, Istio or Linkerd service meshes provide weighted routing:",[298,299,303],"pre",{"className":300,"code":301,"language":302,"meta":105,"style":105},"language-yaml shiki shiki-themes github-dark","apiVersion: networking.istio.io/v1beta1\nkind: VirtualService\nmetadata:\n name: api-service\nspec:\n hosts:\n - api.example.com\n http:\n - route:\n - destination:\n host: api-service\n subset: stable\n weight: 95\n - destination:\n host: api-service\n subset: canary\n weight: 5\n","yaml",[304,305,306,323,333,341,352,359,367,376,384,393,403,413,424,436,445,454,464],"code",{"__ignoreMap":105},[307,308,311,315,319],"span",{"class":309,"line":310},"line",1,[307,312,314],{"class":313},"s4JwU","apiVersion",[307,316,318],{"class":317},"s95oV",": ",[307,320,322],{"class":321},"sU2Wk","networking.istio.io/v1beta1\n",[307,324,325,328,330],{"class":309,"line":109},[307,326,327],{"class":313},"kind",[307,329,318],{"class":317},[307,331,332],{"class":321},"VirtualService\n",[307,334,335,338],{"class":309,"line":106},[307,336,337],{"class":313},"metadata",[307,339,340],{"class":317},":\n",[307,342,344,347,349],{"class":309,"line":343},4,[307,345,346],{"class":313}," name",[307,348,318],{"class":317},[307,350,351],{"class":321},"api-service\n",[307,353,354,357],{"class":309,"line":266},[307,355,356],{"class":313},"spec",[307,358,340],{"class":317},[307,360,362,365],{"class":309,"line":361},6,[307,363,364],{"class":313}," hosts",[307,366,340],{"class":317},[307,368,370,373],{"class":309,"line":369},7,[307,371,372],{"class":317}," - ",[307,374,375],{"class":321},"api.example.com\n",[307,377,379,382],{"class":309,"line":378},8,[307,380,381],{"class":313}," http",[307,383,340],{"class":317},[307,385,386,388,391],{"class":309,"line":129},[307,387,372],{"class":317},[307,389,390],{"class":313},"route",[307,392,340],{"class":317},[307,394,396,398,401],{"class":309,"line":395},10,[307,397,372],{"class":317},[307,399,400],{"class":313},"destination",[307,402,340],{"class":317},[307,404,406,409,411],{"class":309,"line":405},11,[307,407,408],{"class":313}," host",[307,410,318],{"class":317},[307,412,351],{"class":321},[307,414,416,419,421],{"class":309,"line":415},12,[307,417,418],{"class":313}," subset",[307,420,318],{"class":317},[307,422,423],{"class":321},"stable\n",[307,425,427,430,432],{"class":309,"line":426},13,[307,428,429],{"class":313}," weight",[307,431,318],{"class":317},[307,433,435],{"class":434},"sDLfK","95\n",[307,437,439,441,443],{"class":309,"line":438},14,[307,440,372],{"class":317},[307,442,400],{"class":313},[307,444,340],{"class":317},[307,446,448,450,452],{"class":309,"line":447},15,[307,449,408],{"class":313},[307,451,318],{"class":317},[307,453,351],{"class":321},[307,455,457,459,461],{"class":309,"line":456},16,[307,458,418],{"class":313},[307,460,318],{"class":317},[307,462,463],{"class":321},"canary\n",[307,465,467,469,471],{"class":309,"line":466},17,[307,468,429],{"class":313},[307,470,318],{"class":317},[307,472,473],{"class":434},"5\n",[20,475,476],{},"Without a service mesh, load balancer target group weighting achieves the same result. AWS ALB supports weighted target groups. Nginx can weight upstream servers. Cloudflare Workers can implement percentage-based routing at the edge.",[20,478,479],{},"The initial canary percentage should be small — 1% to 5% of traffic. This limits the blast radius if the release is bad while still generating enough traffic to produce statistically meaningful metrics. For a service handling 10,000 requests per minute, 5% gives you 500 requests per minute on the canary — enough to detect error rate increases within a few minutes.",[20,481,482],{},"Session affinity matters for canary deployments. A single user should consistently hit either the canary or the stable version, not bounce between them. Switching versions mid-session can cause subtle bugs — cached client state that does not match server state, UI inconsistencies between page loads. Route users based on a stable identifier (user ID hash, cookie value) rather than random per-request distribution.",[15,484,486],{"id":485},"metric-based-promotion","Metric-Based Promotion",[20,488,489],{},"The canary's health is determined by comparing its metrics against the stable version's metrics. The key metrics are:",[20,491,492,495],{},[178,493,494],{},"Error rate"," — are canary requests producing more errors? A statistically significant increase in 5xx responses or application-level errors is a rollback signal.",[20,497,498,501],{},[178,499,500],{},"Latency"," — is the canary slower? Compare p50, p95, and p99 latencies. A p99 regression that does not appear in p50 indicates a problem that affects a subset of requests, which is exactly the kind of issue canary deployment is designed to catch.",[20,503,504,507],{},[178,505,506],{},"Business metrics"," — are conversion rates, checkout completions, or other business KPIs different? This requires enough traffic and time to be statistically significant, which is why canary deployments for revenue-critical paths often run for hours.",[298,509,513],{"className":510,"code":511,"language":512,"meta":105,"style":105},"language-ts shiki shiki-themes github-dark","interface CanaryMetrics {\n errorRate: number\n p50Latency: number\n p95Latency: number\n p99Latency: number\n}\n\nFunction shouldPromote(stable: CanaryMetrics, canary: CanaryMetrics): boolean {\n const errorThreshold = 1.1 // 10% higher error rate = rollback\n const latencyThreshold = 1.2 // 20% higher latency = rollback\n\n if (canary.errorRate > stable.errorRate * errorThreshold) return false\n if (canary.p99Latency > stable.p99Latency * latencyThreshold) return false\n\n return true\n}\n","ts",[304,514,515,528,540,549,558,567,572,577,588,606,621,625,651,672,676,684],{"__ignoreMap":105},[307,516,517,521,525],{"class":309,"line":310},[307,518,520],{"class":519},"snl16","interface",[307,522,524],{"class":523},"svObZ"," CanaryMetrics",[307,526,527],{"class":317}," {\n",[307,529,530,534,537],{"class":309,"line":109},[307,531,533],{"class":532},"s9osk"," errorRate",[307,535,536],{"class":519},":",[307,538,539],{"class":434}," number\n",[307,541,542,545,547],{"class":309,"line":106},[307,543,544],{"class":532}," p50Latency",[307,546,536],{"class":519},[307,548,539],{"class":434},[307,550,551,554,556],{"class":309,"line":343},[307,552,553],{"class":532}," p95Latency",[307,555,536],{"class":519},[307,557,539],{"class":434},[307,559,560,563,565],{"class":309,"line":266},[307,561,562],{"class":532}," p99Latency",[307,564,536],{"class":519},[307,566,539],{"class":434},[307,568,569],{"class":309,"line":361},[307,570,571],{"class":317},"}\n",[307,573,574],{"class":309,"line":369},[307,575,576],{"emptyLinePlaceholder":127},"\n",[307,578,579,582,585],{"class":309,"line":378},[307,580,581],{"class":317},"Function ",[307,583,584],{"class":523},"shouldPromote",[307,586,587],{"class":317},"(stable: CanaryMetrics, canary: CanaryMetrics): boolean {\n",[307,589,590,593,596,599,602],{"class":309,"line":129},[307,591,592],{"class":519}," const",[307,594,595],{"class":434}," errorThreshold",[307,597,598],{"class":519}," =",[307,600,601],{"class":434}," 1.1",[307,603,605],{"class":604},"sAwPA"," // 10% higher error rate = rollback\n",[307,607,608,610,613,615,618],{"class":309,"line":395},[307,609,592],{"class":519},[307,611,612],{"class":434}," latencyThreshold",[307,614,598],{"class":519},[307,616,617],{"class":434}," 1.2",[307,619,620],{"class":604}," // 20% higher latency = rollback\n",[307,622,623],{"class":309,"line":405},[307,624,576],{"emptyLinePlaceholder":127},[307,626,627,630,633,636,639,642,645,648],{"class":309,"line":415},[307,628,629],{"class":519}," if",[307,631,632],{"class":317}," (canary.errorRate ",[307,634,635],{"class":519},">",[307,637,638],{"class":317}," stable.errorRate ",[307,640,641],{"class":519},"*",[307,643,644],{"class":317}," errorThreshold) ",[307,646,647],{"class":519},"return",[307,649,650],{"class":434}," false\n",[307,652,653,655,658,660,663,665,668,670],{"class":309,"line":426},[307,654,629],{"class":519},[307,656,657],{"class":317}," (canary.p99Latency ",[307,659,635],{"class":519},[307,661,662],{"class":317}," stable.p99Latency ",[307,664,641],{"class":519},[307,666,667],{"class":317}," latencyThreshold) ",[307,669,647],{"class":519},[307,671,650],{"class":434},[307,673,674],{"class":309,"line":438},[307,675,576],{"emptyLinePlaceholder":127},[307,677,678,681],{"class":309,"line":447},[307,679,680],{"class":519}," return",[307,682,683],{"class":434}," true\n",[307,685,686],{"class":309,"line":456},[307,687,571],{"class":317},[20,689,690],{},"Automated promotion pipelines evaluate these metrics at each stage. A typical progression: 5% for 10 minutes, then 25% for 10 minutes, then 50% for 15 minutes, then 100%. At each stage, metrics are compared. If any threshold is exceeded, the canary is automatically rolled back to 0%.",[20,692,693,694,698],{},"Tools like Flagger (for Kubernetes) and AWS CodeDeploy automate this entire progression. They monitor the metrics you configure, advance through the traffic stages, and roll back automatically on threshold violations. Setting up ",[27,695,697],{"href":696},"/blog/infrastructure-monitoring","infrastructure monitoring"," is a prerequisite — you cannot do metric-based promotion without reliable metrics.",[15,700,702],{"id":701},"automated-rollback","Automated Rollback",[20,704,705],{},"Automatic rollback is the safety net that makes canary deployment practical. Without it, someone has to watch dashboards and manually revert, which means rollback speed depends on human response time — often minutes, sometimes hours.",[20,707,708],{},"The rollback trigger should be:",[710,711,712,719,725],"ol",{},[713,714,715,718],"li",{},[178,716,717],{},"Metric threshold exceeded"," — error rate or latency exceeds the defined bounds",[713,720,721,724],{},[178,722,723],{},"Health check failure"," — the canary instances fail their readiness checks",[713,726,727,730],{},[178,728,729],{},"Alert fired"," — an alerting system detects an anomaly in canary traffic",[20,732,733],{},"Rollback is simple: set the canary traffic weight to 0% and scale down the canary instances. No code revert is needed because the stable version is still running. The canary version just stops receiving traffic.",[298,735,739],{"className":736,"code":737,"language":738,"meta":105,"style":105},"language-bash shiki shiki-themes github-dark","# Immediate rollback: route all traffic to stable\nkubectl patch virtualservice api-service --type merge -p '\nspec:\n http:\n - route:\n - destination:\n host: api-service\n subset: stable\n weight: 100\n - destination:\n host: api-service\n subset: canary\n weight: 0\n'\n","bash",[304,740,741,746,772,777,782,787,792,797,802,807,811,815,820,825],{"__ignoreMap":105},[307,742,743],{"class":309,"line":310},[307,744,745],{"class":604},"# Immediate rollback: route all traffic to stable\n",[307,747,748,751,754,757,760,763,766,769],{"class":309,"line":109},[307,749,750],{"class":523},"kubectl",[307,752,753],{"class":321}," patch",[307,755,756],{"class":321}," virtualservice",[307,758,759],{"class":321}," api-service",[307,761,762],{"class":434}," --type",[307,764,765],{"class":321}," merge",[307,767,768],{"class":434}," -p",[307,770,771],{"class":321}," '\n",[307,773,774],{"class":309,"line":106},[307,775,776],{"class":321},"spec:\n",[307,778,779],{"class":309,"line":343},[307,780,781],{"class":321}," http:\n",[307,783,784],{"class":309,"line":266},[307,785,786],{"class":321}," - route:\n",[307,788,789],{"class":309,"line":361},[307,790,791],{"class":321}," - destination:\n",[307,793,794],{"class":309,"line":369},[307,795,796],{"class":321}," host: api-service\n",[307,798,799],{"class":309,"line":378},[307,800,801],{"class":321}," subset: stable\n",[307,803,804],{"class":309,"line":129},[307,805,806],{"class":321}," weight: 100\n",[307,808,809],{"class":309,"line":395},[307,810,791],{"class":321},[307,812,813],{"class":309,"line":405},[307,814,796],{"class":321},[307,816,817],{"class":309,"line":415},[307,818,819],{"class":321}," subset: canary\n",[307,821,822],{"class":309,"line":426},[307,823,824],{"class":321}," weight: 0\n",[307,826,827],{"class":309,"line":438},[307,828,829],{"class":321},"'\n",[20,831,832],{},"The time between a problem starting and the rollback completing is your exposure window. Automated metric-based rollback keeps this under 5 minutes for most configurations. Manual rollback can take 15-30 minutes — the time for an alert to fire, a human to investigate, and a decision to revert. That difference matters for a service handling thousands of requests per minute.",[15,834,836],{"id":835},"observability-requirements","Observability Requirements",[20,838,839],{},"Canary deployment demands better observability than simpler strategies. You need to compare metrics between two versions running simultaneously, which means your metrics and logs must be tagged with the version that produced them.",[20,841,842],{},"Every log line, metric data point, and trace span should include the deployment version as a label:",[298,844,846],{"className":510,"code":845,"language":512,"meta":105,"style":105},"logger.info('Request processed', {\n version: process.env.APP_VERSION,\n duration: elapsed,\n status: response.statusCode,\n})\n",[304,847,848,865,876,881,886],{"__ignoreMap":105},[307,849,850,853,856,859,862],{"class":309,"line":310},[307,851,852],{"class":317},"logger.",[307,854,855],{"class":523},"info",[307,857,858],{"class":317},"(",[307,860,861],{"class":321},"'Request processed'",[307,863,864],{"class":317},", {\n",[307,866,867,870,873],{"class":309,"line":109},[307,868,869],{"class":317}," version: process.env.",[307,871,872],{"class":434},"APP_VERSION",[307,874,875],{"class":317},",\n",[307,877,878],{"class":309,"line":106},[307,879,880],{"class":317}," duration: elapsed,\n",[307,882,883],{"class":309,"line":343},[307,884,885],{"class":317}," status: response.statusCode,\n",[307,887,888],{"class":309,"line":266},[307,889,890],{"class":317},"})\n",[20,892,893],{},"Your monitoring dashboards need side-by-side comparison views. A single \"error rate\" graph that aggregates both versions hides the canary's impact. You need \"error rate by version\" to see whether the canary is producing more errors than the stable version.",[20,895,896,897,901],{},"Distributed tracing becomes essential for diagnosing canary issues. When the canary's latency is higher, tracing shows which specific operation is slower — is it the database, a downstream service, or the new code path? Without tracing, you know the canary is slow but not why. The ",[27,898,900],{"href":899},"/blog/log-aggregation-architecture","log aggregation architecture"," needed for canary analysis is the same infrastructure that serves general operational visibility.",[20,903,904,905,909],{},"Canary deployment is more complex to set up than ",[27,906,908],{"href":907},"/blog/blue-green-deployment-guide","blue-green switching",", but it provides gradual validation that blue-green cannot. For high-traffic services where a bad release affects thousands of users per second, the investment in canary infrastructure pays for itself the first time it catches a regression that staging missed.",[911,912,913],"style",{},"html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}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 .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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":105,"searchDepth":106,"depth":106,"links":915},[916,917,918,919],{"id":289,"depth":109,"text":290},{"id":485,"depth":109,"text":486},{"id":701,"depth":109,"text":702},{"id":835,"depth":109,"text":836},"DevOps","2025-12-28","Implement canary deployments to validate releases with real traffic — traffic splitting, metric-based promotion, automated rollback, and observability requirements.",[924,925],"canary deployment strategy","canary release production testing",{},"/blog/canary-deployment-strategy",{"title":277,"description":922},"blog/canary-deployment-strategy",[931,920,932],"Deployments","Observability","hu5cm1_4_ewktcOkfpD_AJIZF64i9-jM5JzwpoE6pcE",{"id":935,"title":936,"author":937,"body":938,"category":1139,"date":921,"description":1140,"extension":116,"featured":117,"image":118,"keywords":1141,"meta":1144,"navigation":127,"path":1145,"readTime":369,"seo":1146,"stem":1147,"tags":1148,"__hash__":1151},"blog/blog/custom-dashboard-development.md","Building Custom Dashboards That People Actually Use",{"name":9,"bio":10},{"type":12,"value":939,"toc":1131},[940,944,947,950,953,956,960,963,969,980,986,992,994,998,1001,1007,1013,1023,1029,1037,1039,1043,1046,1052,1058,1064,1070,1076,1078,1082,1085,1091,1097,1103,1106,1108,1112],[15,941,943],{"id":942},"the-dashboard-nobody-uses","The Dashboard Nobody Uses",[20,945,946],{},"Every enterprise application has a dashboard. Most of them are ignored. They're built during the initial development push, populated with charts that seemed important at the time, and then left unchanged as the product evolves and users develop their actual workflows.",[20,948,949],{},"The problem isn't technical. It's that most dashboards are designed around data availability rather than user needs. Someone looks at the database schema, picks the metrics that are easy to calculate, and puts them on a page. The result is a wall of charts that shows data without providing insight.",[20,951,952],{},"A dashboard that people actually use does three things: it answers the questions users have when they first open the application, it highlights situations that require attention, and it provides shortcuts to the actions users take most frequently. Building this requires understanding your users before writing any code.",[954,955],"hr",{},[15,957,959],{"id":958},"designing-for-user-intent","Designing for User Intent",[20,961,962],{},"Different users open your application with different questions. A sales manager asks \"How is my team performing this week?\" An operations manager asks \"Are there any problems I need to address right now?\" A customer success manager asks \"Which accounts need attention?\"",[20,964,965,968],{},[178,966,967],{},"Start with user research."," Interview users from each role that will see the dashboard. Ask them: what's the first thing you want to know when you open this application? What situations require your immediate attention? What actions do you take most frequently? The answers to these questions define what belongs on the dashboard.",[20,970,971,974,975,979],{},[178,972,973],{},"Role-based dashboards"," present different information to different users. A one-size-fits-all dashboard inevitably includes metrics that are irrelevant to most users, which trains them to ignore the dashboard entirely. A dashboard tailored to the user's role shows only what matters to them. Implementing this requires your ",[27,976,978],{"href":977},"/blog/role-based-access-control-guide","role-based access control"," system to inform the dashboard composition, not just the feature access.",[20,981,982,985],{},[178,983,984],{},"Information hierarchy"," organizes the dashboard from most important to least important. The most critical metrics or alerts should be visible without scrolling. Supporting details should be accessible but not prominent. This hierarchy should reflect actual importance to the user's daily workflow, not the visual impressiveness of the chart type.",[20,987,988,991],{},[178,989,990],{},"Actionable over informational."," Every element on the dashboard should either answer a question or enable an action. A chart that shows revenue over time is informational. A chart that shows revenue over time with a callout highlighting a significant deviation and a link to investigate is actionable. The second version is worth building. The first is decoration.",[954,993],{},[15,995,997],{"id":996},"data-architecture-for-dashboards","Data Architecture for Dashboards",[20,999,1000],{},"Dashboard performance is a common frustration. Complex queries against production databases make dashboards slow, which makes users stop visiting them.",[20,1002,1003,1006],{},[178,1004,1005],{},"Pre-computed aggregations"," are the most effective performance strategy. Instead of running aggregate queries on every dashboard load, compute the metrics in a background job and store the results. The dashboard reads pre-computed values, which is fast regardless of the underlying data volume. The tradeoff is data freshness — pre-computed metrics are only as current as the last computation run.",[20,1008,1009,1012],{},[178,1010,1011],{},"Materialized views"," (in PostgreSQL) or computed tables provide a database-level solution. Define the aggregation query as a materialized view, refresh it periodically, and have the dashboard query the materialized view instead of the base tables. This keeps the logic in SQL and leverages database optimizations for the aggregation.",[20,1014,1015,1018,1019,1022],{},[178,1016,1017],{},"Time-series data"," for trend charts should be stored in a structure optimized for time-range queries. A dedicated metrics table with ",[304,1020,1021],{},"(metric_name, period, value, tenant_id)"," columns, indexed on period and tenant_id, supports efficient range queries for chart rendering. Computing these metrics incrementally (updating today's value as events occur rather than recalculating from scratch) keeps the computation cost proportional to activity, not data volume.",[20,1024,1025,1028],{},[178,1026,1027],{},"Caching"," at the API layer reduces database load for frequently-accessed dashboards. Cache dashboard responses with short TTLs (1-5 minutes) for real-time dashboards or longer TTLs (15-60 minutes) for analytical dashboards. Invalidate the cache when underlying data changes significantly rather than relying solely on TTL expiration.",[20,1030,1031,1032,1036],{},"For multi-tenant applications, all dashboard queries must be tenant-scoped. A dashboard that accidentally aggregates data across tenants isn't just a bug — it's a ",[27,1033,1035],{"href":1034},"/blog/saas-tenant-isolation","data isolation violation",".",[954,1038],{},[15,1040,1042],{"id":1041},"frontend-implementation-patterns","Frontend Implementation Patterns",[20,1044,1045],{},"The frontend architecture of a dashboard affects both performance and maintainability.",[20,1047,1048,1051],{},[178,1049,1050],{},"Component-based dashboard composition"," treats each dashboard widget as an independent component with its own data fetching, loading state, and error handling. A failing widget shouldn't prevent the rest of the dashboard from rendering. Each widget fetches its data independently, shows its own loading skeleton during fetch, and displays a graceful error state if the fetch fails.",[20,1053,1054,1057],{},[178,1055,1056],{},"Progressive loading"," renders the dashboard layout immediately and populates widgets as their data arrives. This gives users a sense of progress — they can see the dashboard structure and start reading the first widgets while later widgets are still loading. This is dramatically better than a spinner covering the entire page until all data is ready.",[20,1059,1060,1063],{},[178,1061,1062],{},"Responsive grid layouts"," adapt the dashboard to different screen sizes. A 4-column layout on desktop should reorganize to 2 columns on tablet and 1 column on mobile, with the most important widgets maintaining their position at the top. CSS Grid with named template areas makes this responsive reorganization straightforward.",[20,1065,1066,1069],{},[178,1067,1068],{},"Interactive charts"," should be purposeful, not gratuitous. Hover tooltips that show exact values, click-to-filter that narrows a chart to a specific segment, and drill-down that navigates to a detail view — these interactions make charts useful. Animation for its own sake adds visual noise without information value.",[20,1071,1072,1075],{},[178,1073,1074],{},"Date range selection"," is the most common dashboard interaction. Users want to see data for this week, last month, this quarter, or a custom range. The date range selector should be prominent, apply to all widgets consistently, and maintain the selected range across page navigations.",[954,1077],{},[15,1079,1081],{"id":1080},"customization-and-user-preferences","Customization and User Preferences",[20,1083,1084],{},"The most effective dashboards let users adapt them to their needs.",[20,1086,1087,1090],{},[178,1088,1089],{},"Widget visibility"," lets users hide widgets they don't use and rearrange the ones they keep. This personalization ensures the dashboard evolves with the user's changing needs without requiring engineering changes.",[20,1092,1093,1096],{},[178,1094,1095],{},"Saved views"," let users create named dashboard configurations for different contexts. A manager might have a \"morning standup\" view with team metrics and a \"weekly review\" view with trend charts. Each view is a named configuration of visible widgets, date range, and filters.",[20,1098,1099,1102],{},[178,1100,1101],{},"Default dashboards"," should be excellent out of the box. Customization is a power feature, not a substitute for good defaults. If users need to customize the dashboard before it's useful, the defaults are wrong.",[20,1104,1105],{},"Build dashboards that earn their place on the screen. Every chart, every metric, every widget should justify its presence by helping the user make better decisions or take faster action. A dashboard with three essential widgets is more valuable than one with fifteen that nobody reads.",[954,1107],{},[15,1109,1111],{"id":1110},"keep-reading","Keep Reading",[1113,1114,1115,1121,1126],"ul",{},[713,1116,1117],{},[27,1118,1120],{"href":1119},"/blog/custom-reporting-system","Building Custom Reporting Systems: Architecture and Patterns",[713,1122,1123],{},[27,1124,1125],{"href":977},"Role-Based Access Control: Design and Implementation",[713,1127,1128],{},[27,1129,1130],{"href":1034},"Tenant Isolation in SaaS: Security and Performance",{"title":105,"searchDepth":106,"depth":106,"links":1132},[1133,1134,1135,1136,1137,1138],{"id":942,"depth":109,"text":943},{"id":958,"depth":109,"text":959},{"id":996,"depth":109,"text":997},{"id":1041,"depth":109,"text":1042},{"id":1080,"depth":109,"text":1081},{"id":1110,"depth":109,"text":1111},"Frontend","Most dashboards are walls of charts nobody looks at. Here's how to build dashboards that surface actionable information and become part of daily workflow.",[1142,1143],"custom dashboard development","dashboard design patterns",{},"/blog/custom-dashboard-development",{"title":936,"description":1140},"blog/custom-dashboard-development",[1139,1149,1150],"Dashboard","UX Design","DTqNQl5K__G4NckrNopSD5GOLmOA7tnojmIEr2cg4dE",{"id":1153,"title":1154,"author":1155,"body":1156,"category":113,"date":921,"description":1448,"extension":116,"featured":117,"image":118,"keywords":1449,"meta":1455,"navigation":127,"path":1456,"readTime":369,"seo":1457,"stem":1458,"tags":1459,"__hash__":1465},"blog/blog/place-names-celtic-history.md","Reading the Landscape: Celtic Place Names and Hidden History",{"name":9,"bio":10},{"type":12,"value":1157,"toc":1439},[1158,1162,1165,1168,1171,1175,1182,1192,1202,1212,1222,1228,1231,1235,1238,1253,1259,1265,1271,1277,1283,1293,1299,1302,1306,1309,1318,1327,1333,1342,1348,1351,1355,1358,1364,1370,1380,1386,1392,1400,1404,1411,1414,1416,1420],[15,1159,1161],{"id":1160},"the-map-as-archive","The Map as Archive",[20,1163,1164],{},"Long after the last native speaker of a language has died, the place names survive. They are the most durable artifacts of any linguistic culture -- more enduring than manuscripts, more persistent than oral traditions, more resistant to conquest and assimilation than any other form of cultural memory.",[20,1166,1167],{},"Across the British Isles and continental Europe, thousands of place names preserve Celtic vocabulary from languages that have been extinct for centuries or millennia. The names of rivers, mountains, settlements, and fields encode information about the people who named them, the features they considered important, and the language they spoke when they did it.",[20,1169,1170],{},"Learning to read Celtic place names is like developing a new sense for the landscape -- a way of hearing what the map is trying to tell you about who was here before.",[15,1172,1174],{"id":1173},"the-river-names-europes-oldest-layer","The River Names: Europe's Oldest Layer",[20,1176,1177,1178,1181],{},"The oldest surviving place names in Europe are river names -- ",[234,1179,1180],{},"hydronyms"," -- and many of them are pre-Celtic or early Celtic in origin. Rivers are named early, and their names persist even when the language of the surrounding population changes entirely.",[20,1183,1184,1187,1188,1191],{},[178,1185,1186],{},"Thames"," -- from a Celtic root meaning \"dark\" (compare Welsh ",[234,1189,1190],{},"tywyll",").",[20,1193,1194,1197,1198,1201],{},[178,1195,1196],{},"Danube"," -- from a Celtic root ",[234,1199,1200],{},"danu",", meaning \"river\" or \"flowing water,\" preserved in the name of the Irish goddess Danu and in other Celtic river names across Europe.",[20,1203,1204,1207,1208,1211],{},[178,1205,1206],{},"Rhine"," -- from a Celtic root meaning \"to flow,\" cognate with Irish ",[234,1209,1210],{},"rian"," (sea, way).",[20,1213,1214,1217,1218,1221],{},[178,1215,1216],{},"Avon"," -- simply the Brythonic Celtic word for \"river\" (",[234,1219,1220],{},"abona","), which English-speakers adopted as a proper name without realizing it already meant \"river.\" There are at least eight rivers called Avon in Britain, each one a fossilized Celtic word hidden in plain sight.",[20,1223,1224,1227],{},[178,1225,1226],{},"Tay, Tees, Tamar"," -- all from Celtic roots related to water and flowing.",[20,1229,1230],{},"These names predate the English language by over a thousand years. They predate the Anglo-Saxon settlement by centuries. Some may predate the Celtic languages themselves, representing a pre-Indo-European substrate that the earliest Celtic speakers adopted when they arrived.",[15,1232,1234],{"id":1233},"the-gaelic-names-of-scotland","The Gaelic Names of Scotland",[20,1236,1237],{},"Scottish place names are overwhelmingly Gaelic in origin, reflecting the language's dominance in the Highlands from the early medieval period until the modern era. Common Gaelic elements include:",[20,1239,1240,1243,1244,1247,1248,1252],{},[178,1241,1242],{},"Bal- / Bally-"," (from ",[234,1245,1246],{},"baile",", meaning \"settlement, farm\"): Balnagown (the ancestral ",[27,1249,1251],{"href":1250},"/blog/balnagown-castle-ross-clan","seat of Clan Ross","), Balmoral, Balloch, Balquhidder.",[20,1254,1255,1258],{},[178,1256,1257],{},"Ben / Beinn"," (mountain): Ben Nevis, Ben Lomond, Ben Wyvis (in Ross-shire).",[20,1260,1261,1264],{},[178,1262,1263],{},"Glen / Gleann"," (valley): Glencoe, Glenelg, Glen Affric.",[20,1266,1267,1270],{},[178,1268,1269],{},"Inver- / Inbhir"," (river mouth): Inverness (\"mouth of the Ness\"), Invergordon, Inverkeithing.",[20,1272,1273,1276],{},[178,1274,1275],{},"Kin- / Ceann"," (head, headland): Kintyre (\"head of the land\"), Kintail, Kinlochewe.",[20,1278,1279,1282],{},[178,1280,1281],{},"Strath / Srath"," (wide valley): Strathconon, Strathpeffer, Strathmore.",[20,1284,1285,1288,1289,1292],{},[178,1286,1287],{},"Dun / Dun"," (fort, fortified place): Dundee, Dunrobin, Dunblane, Dingwall (from Norse ",[234,1290,1291],{},"thing-vollr",", but the Gaelic alternative is Inbhir Pheofharain).",[20,1294,1295,1298],{},[178,1296,1297],{},"Ach- / Achadh"," (field): Achiltibuie, Achmore, Achnasheen.",[20,1300,1301],{},"The Gaelic elements in Scottish place names are not merely decorative. They record the specific features of the landscape that mattered to the people who lived there: where the river met the sea, where the valley widened enough for farming, where the fort stood, where the cattle grazed. Each name is a compressed description of a place, written in a language that has spoken these hills for fifteen hundred years.",[15,1303,1305],{"id":1304},"the-brythonic-layer","The Brythonic Layer",[20,1307,1308],{},"Beneath the Gaelic layer in Scotland, and across Wales and northern England, lies a Brythonic Celtic substrate -- the remnant of the P-Celtic languages that preceded Gaelic in much of Britain.",[20,1310,1311,1314,1315,1036],{},[178,1312,1313],{},"Aber-"," (river mouth): Aberdeen, Aberystwyth, Aberdour. This is the Brythonic equivalent of Gaelic ",[234,1316,1317],{},"inbhir",[20,1319,1320,1323,1324,1036],{},[178,1321,1322],{},"Pen- / Penn"," (head, top): Penzance, Penrith, Penicuik. The Brythonic equivalent of Gaelic ",[234,1325,1326],{},"ceann",[20,1328,1329,1332],{},[178,1330,1331],{},"Llan-"," (church, enclosure): Llandudno, Llanelli, Llangollen. This element is predominantly Welsh and reflects the early Christian settlement pattern of enclosed church communities.",[20,1334,1335,1338,1339,1036],{},[178,1336,1337],{},"Caer-"," (fort): Carlisle (from Caer Luel), Cardiff (Caerdydd), Caernarfon. The Brythonic equivalent of Gaelic ",[234,1340,1341],{},"dun",[20,1343,1344,1347],{},[178,1345,1346],{},"Coed / Coit"," (wood, forest): Betws-y-Coed, Coity.",[20,1349,1350],{},"In southern Scotland, Brythonic place names survive from the period before Gaelic expansion: Lanark, Penicuik, and the entire kingdom name of Strathclyde (Srath Chluaidh, \"valley of the Clyde\" -- the river name itself being Brythonic).",[15,1352,1354],{"id":1353},"the-continental-celtic-layer","The Continental Celtic Layer",[20,1356,1357],{},"Across France, Spain, Switzerland, and beyond, Celtic place names survive from the pre-Roman period, often adapted through Latin and then through the local Romance language.",[20,1359,1360,1363],{},[178,1361,1362],{},"Lyon"," -- from Lugdunum, \"fort of Lugh,\" the Celtic god of light and skill. The same god appears in Irish as Lugh and in Welsh as Lleu.",[20,1365,1366,1369],{},[178,1367,1368],{},"Paris"," -- from the Parisii, a Celtic tribe. The name is Celtic in origin.",[20,1371,1372,1375,1376,1379],{},[178,1373,1374],{},"London"," -- probably from a Celtic root ",[234,1377,1378],{},"Londinium",", possibly meaning \"place at the navigable river\" or related to a personal name. The etymology is disputed but almost certainly Celtic.",[20,1381,1382,1385],{},[178,1383,1384],{},"Milan"," -- from Mediolanum, a Celtic word meaning \"middle plain,\" used for multiple settlements across the Celtic world.",[20,1387,1388,1391],{},[178,1389,1390],{},"Bohemia"," -- from the Boii, a Celtic tribe who gave their name to the region before Germanic and Slavic populations displaced them.",[20,1393,1394,1395,1399],{},"These names are the last traces of the ",[27,1396,1398],{"href":1397},"/blog/celtic-languages-family-tree","continental Celtic languages"," that once dominated Western Europe. The languages died. The names remained.",[15,1401,1403],{"id":1402},"reading-your-own-landscape","Reading Your Own Landscape",[20,1405,1406,1407,1410],{},"For anyone researching ancestry in Scotland, Ireland, Wales, or anywhere in the former Celtic world, place names are primary sources. The name of the parish where your ancestor was baptized, the townland where they farmed, the estate from which they were ",[27,1408,1409],{"href":101},"cleared"," -- each of these names carries information about the linguistic and cultural history of the place.",[20,1412,1413],{},"Learning even a handful of Gaelic or Brythonic place-name elements transforms the map from a collection of arbitrary labels into a readable document, layered with the voices of the people who named the rivers, the mountains, and the settlements centuries before anyone thought to write their names down.",[954,1415],{},[15,1417,1419],{"id":1418},"related-articles","Related Articles",[1113,1421,1422,1427,1433],{},[713,1423,1424],{},[27,1425,1426],{"href":1397},"The Celtic Language Family: From Galatian to Gaelic",[713,1428,1429],{},[27,1430,1432],{"href":1431},"/blog/scottish-surnames-origins","Scottish Surnames: What Your Name Reveals About Your Ancestors",[713,1434,1435],{},[27,1436,1438],{"href":1437},"/blog/ross-shire-geography-history","Ross-shire: The Land That Shaped a Clan",{"title":105,"searchDepth":106,"depth":106,"links":1440},[1441,1442,1443,1444,1445,1446,1447],{"id":1160,"depth":109,"text":1161},{"id":1173,"depth":109,"text":1174},{"id":1233,"depth":109,"text":1234},{"id":1304,"depth":109,"text":1305},{"id":1353,"depth":109,"text":1354},{"id":1402,"depth":109,"text":1403},{"id":1418,"depth":109,"text":1419},"Across Britain, Ireland, and continental Europe, Celtic place names preserve a linguistic record of peoples and languages that have otherwise vanished. Here is how to decode the landscape and find the hidden history in the names on the map.",[1450,1451,1452,1453,1454],"celtic place names","scottish place names meaning","irish place names gaelic","celtic toponymy","place name origins britain",{},"/blog/place-names-celtic-history",{"title":1154,"description":1448},"blog/place-names-celtic-history",[1460,1461,1462,1463,1464],"Place Names","Celtic Languages","Toponymy","Scottish History","Irish History","H9p4Jt2SHBhNojDymB_DZ0TpFuBo7oX3QBl8xC7XOAM",{"id":1467,"title":1468,"author":1469,"body":1470,"category":1607,"date":921,"description":1608,"extension":116,"featured":117,"image":118,"keywords":1609,"meta":1612,"navigation":127,"path":1613,"readTime":378,"seo":1614,"stem":1615,"tags":1616,"__hash__":1620},"blog/blog/rate-limiting-algorithms.md","Rate Limiting Algorithms: Token Bucket, Sliding Window, and More",{"name":9,"bio":10},{"type":12,"value":1471,"toc":1601},[1472,1476,1479,1482,1485,1487,1491,1497,1500,1506,1512,1518,1521,1527,1529,1533,1539,1545,1567,1573,1575,1579,1582,1585,1588],[15,1473,1475],{"id":1474},"why-rate-limiting-is-a-design-problem-not-just-a-security-feature","Why Rate Limiting Is a Design Problem, Not Just a Security Feature",[20,1477,1478],{},"Rate limiting is usually introduced as a security measure — protect your API from abuse, prevent DDoS attacks, stop malicious bots. These are valid motivations, but they undersell the concept. Rate limiting is fundamentally about resource management: ensuring that your system provides consistent service to all users by preventing any single user or pattern of usage from consuming disproportionate resources.",[20,1480,1481],{},"Without rate limiting, a single customer's automated script can degrade the experience for every other customer. A misconfigured integration partner can send ten thousand requests per second and effectively take your API offline. A legitimate traffic spike can overwhelm your database connection pool and cascade into failures across your entire system.",[20,1483,1484],{},"The algorithm you choose for rate limiting determines the behavior characteristics of your system under load — how smooth the rate enforcement is, how it handles bursts, and how fairly it distributes capacity across users. Each algorithm makes different trade-offs, and understanding those trade-offs is essential for choosing the right one.",[954,1486],{},[15,1488,1490],{"id":1489},"the-algorithms-compared","The Algorithms Compared",[20,1492,1493,1496],{},[178,1494,1495],{},"Fixed window counting"," is the simplest approach. Divide time into fixed intervals (e.g., one-minute windows), count requests within each window, and reject requests that exceed the limit. Implementation is straightforward: maintain a counter per user per window in Redis or a similar store, increment on each request, and compare against the threshold.",[20,1498,1499],{},"The weakness is the boundary problem. A user who sends 100 requests in the last second of one window and 100 requests in the first second of the next window has sent 200 requests in two seconds while staying within a 100-per-minute limit in both windows. At the boundary, the effective rate can be double your intended limit.",[20,1501,1502,1505],{},[178,1503,1504],{},"Sliding window log"," solves the boundary problem by tracking the timestamp of every request. When a new request arrives, count the number of timestamps within the past window duration (e.g., 60 seconds) and compare against the limit. This provides exact enforcement but requires storing every timestamp, which can be memory-intensive for high-volume APIs.",[20,1507,1508,1511],{},[178,1509,1510],{},"Sliding window counter"," is a practical compromise. It combines the current window's count with a weighted portion of the previous window's count based on how far into the current window you are. If you're 30 seconds into a 60-second window, the effective count is (current window count) + (previous window count x 0.5). This approximation is close enough for most applications and uses only two counters per user instead of a log of timestamps.",[20,1513,1514,1517],{},[178,1515,1516],{},"Token bucket"," is the most flexible algorithm and the one I reach for most often. Each user has a bucket that holds a maximum number of tokens (the burst limit). Tokens are added at a steady rate (the sustained rate). Each request consumes one token. If the bucket is empty, the request is rejected or queued.",[20,1519,1520],{},"The elegance of token bucket is that it naturally handles both sustained rates and bursts. A user with a bucket capacity of 20 and a refill rate of 10 per second can burst to 20 requests immediately, then sustain 10 per second thereafter. This matches how most real API usage looks — occasional bursts of activity within an overall rate constraint. The implementation requires only two values per user: the current token count and the timestamp of the last refill calculation.",[20,1522,1523,1526],{},[178,1524,1525],{},"Leaky bucket"," processes requests at a fixed rate, like water leaking from a bucket at a constant drip. Requests are queued and processed in order. If the queue is full, new requests are rejected. This produces the smoothest output rate — perfectly uniform — but adds latency because requests wait in the queue. It's appropriate for scenarios where downstream systems require a strictly constant rate of incoming work.",[954,1528],{},[15,1530,1532],{"id":1531},"implementation-decisions","Implementation Decisions",[20,1534,1535,1538],{},[178,1536,1537],{},"Where to enforce."," Rate limiting can happen at the API gateway level, the application level, or both. Gateway-level limiting protects against volume attacks before requests reach your application code. Application-level limiting allows more granular rules — different limits per endpoint, per user tier, or per operation type. For most systems, implement broad protection at the gateway and fine-grained rules in the application.",[20,1540,1541,1544],{},[178,1542,1543],{},"What to limit by."," User ID, API key, IP address, or a combination. User-based limiting is the most fair but requires authentication before the limit check. IP-based limiting works for unauthenticated endpoints but punishes users behind shared IPs (corporate networks, VPNs). API key limiting works well for machine-to-machine APIs. Many systems use IP-based limiting for unauthenticated endpoints and user-based limiting for authenticated ones.",[20,1546,1547,1550,1551,1554,1555,1558,1559,1562,1563,1566],{},[178,1548,1549],{},"How to communicate limits."," Include rate limit information in response headers: ",[304,1552,1553],{},"X-RateLimit-Limit"," (the maximum), ",[304,1556,1557],{},"X-RateLimit-Remaining"," (how many requests are left), and ",[304,1560,1561],{},"X-RateLimit-Reset"," (when the limit resets). When a request is rate-limited, return a 429 status code with a ",[304,1564,1565],{},"Retry-After"," header. This lets well-behaved clients adjust their request patterns proactively rather than hammering your API until they're allowed through.",[20,1568,1569,1572],{},[178,1570,1571],{},"Distributed rate limiting"," is necessary when your API runs on multiple servers. Each server can't maintain its own independent counters because users would get N times the intended limit by rotating across N servers. Centralized counters in Redis are the standard solution, using atomic increment operations (INCR with EXPIRE for fixed windows, or Lua scripts for token bucket) to ensure consistency. The trade-off is an additional network round-trip per request to check the counter, but Redis latency is typically under a millisecond, making this negligible.",[954,1574],{},[15,1576,1578],{"id":1577},"common-pitfalls","Common Pitfalls",[20,1580,1581],{},"Avoid rate limits that punish legitimate usage patterns. If your API serves a dashboard that loads five resources simultaneously, a rate limit of five requests per second means the dashboard fails on load. Understand your clients' actual usage patterns before setting limits. Overly aggressive limits create more support burden than they prevent.",[20,1583,1584],{},"Don't forget to rate-limit internal services. Microservices that call each other without rate limits can create cascading failures when one service slows down and another retries aggressively. Internal rate limiting — or circuit breakers, which are complementary — prevents one struggling service from taking down the entire system.",[20,1586,1587],{},"Test your rate limiting under realistic conditions. A rate limiter that works correctly at 100 requests per second might behave differently at 10,000 requests per second due to Redis contention, clock skew between servers, or counter overflow. Load test the rate limiting infrastructure itself, not just the application behind it.",[20,1589,1590,1591,1595,1596,1600],{},"Rate limiting is one component of a broader API resilience strategy. Combined with proper ",[27,1592,1594],{"href":1593},"/blog/error-handling-patterns","error handling"," and thoughtful ",[27,1597,1599],{"href":1598},"/blog/graphql-vs-rest-guide","API design",", it ensures that your system degrades gracefully under pressure rather than failing catastrophically.",{"title":105,"searchDepth":106,"depth":106,"links":1602},[1603,1604,1605,1606],{"id":1474,"depth":109,"text":1475},{"id":1489,"depth":109,"text":1490},{"id":1531,"depth":109,"text":1532},{"id":1577,"depth":109,"text":1578},"Engineering","How rate limiting algorithms work and when to use each one. Token bucket, sliding window, fixed window, and leaky bucket explained with practical implementation guidance.",[1610,1611],"rate limiting algorithms","token bucket algorithm",{},"/blog/rate-limiting-algorithms",{"title":1468,"description":1608},"blog/rate-limiting-algorithms",[1617,1618,1619],"Rate Limiting","System Design","API Security","JoNRy2kaCTXyVTQlU0d8-GIu5E0-1QYKxwcYvqE-HW8",{"id":1622,"title":1623,"author":1624,"body":1625,"category":1607,"date":921,"description":2094,"extension":116,"featured":117,"image":118,"keywords":2095,"meta":2098,"navigation":127,"path":2099,"readTime":369,"seo":2100,"stem":2101,"tags":2102,"__hash__":2106},"blog/blog/saas-user-management.md","SaaS User Management: Roles, Teams, and Permissions",{"name":9,"bio":10},{"type":12,"value":1626,"toc":2088},[1627,1630,1633,1637,1640,1764,1771,1795,1799,1802,1808,1814,1820,1826,1829,2002,2005,2009,2012,2015,2018,2021,2024,2040,2044,2047,2053,2060,2066,2069,2077,2085],[20,1628,1629],{},"Every SaaS product eventually needs user management — roles that control who can do what, teams that organize users within an account, and invitations that let accounts grow. Building this well from the start prevents the painful migration from \"everyone can do everything\" to \"we need granular permissions\" that happens when your first enterprise customer asks about access controls.",[20,1631,1632],{},"I have built user management systems for SaaS products ranging from small team tools to multi-tenant enterprise platforms. The patterns that work are well established.",[15,1634,1636],{"id":1635},"the-data-model","The Data Model",[20,1638,1639],{},"The foundation of user management is the relationship between users, organizations (tenants), and the roles that connect them. A user can belong to multiple organizations, and their role can differ in each one.",[298,1641,1645],{"className":1642,"code":1643,"language":1644,"meta":105,"style":105},"language-prisma shiki shiki-themes github-dark","model User {\n id String @id @default(cuid())\n email String @unique\n name String\n memberships Membership[]\n}\n\nModel Organization {\n id String @id @default(cuid())\n name String\n slug String @unique\n memberships Membership[]\n}\n\nModel Membership {\n id String @id @default(cuid())\n userId String\n organizationId String\n role Role @default(MEMBER)\n user User @relation(fields: [userId], references: [id])\n organization Organization @relation(fields: [organizationId], references: [id])\n\n @@unique([userId, organizationId])\n}\n","prisma",[304,1646,1647,1652,1657,1662,1667,1672,1676,1680,1685,1689,1693,1698,1702,1706,1710,1715,1719,1724,1730,1736,1742,1748,1753,1759],{"__ignoreMap":105},[307,1648,1649],{"class":309,"line":310},[307,1650,1651],{},"model User {\n",[307,1653,1654],{"class":309,"line":109},[307,1655,1656],{}," id String @id @default(cuid())\n",[307,1658,1659],{"class":309,"line":106},[307,1660,1661],{}," email String @unique\n",[307,1663,1664],{"class":309,"line":343},[307,1665,1666],{}," name String\n",[307,1668,1669],{"class":309,"line":266},[307,1670,1671],{}," memberships Membership[]\n",[307,1673,1674],{"class":309,"line":361},[307,1675,571],{},[307,1677,1678],{"class":309,"line":369},[307,1679,576],{"emptyLinePlaceholder":127},[307,1681,1682],{"class":309,"line":378},[307,1683,1684],{},"Model Organization {\n",[307,1686,1687],{"class":309,"line":129},[307,1688,1656],{},[307,1690,1691],{"class":309,"line":395},[307,1692,1666],{},[307,1694,1695],{"class":309,"line":405},[307,1696,1697],{}," slug String @unique\n",[307,1699,1700],{"class":309,"line":415},[307,1701,1671],{},[307,1703,1704],{"class":309,"line":426},[307,1705,571],{},[307,1707,1708],{"class":309,"line":438},[307,1709,576],{"emptyLinePlaceholder":127},[307,1711,1712],{"class":309,"line":447},[307,1713,1714],{},"Model Membership {\n",[307,1716,1717],{"class":309,"line":456},[307,1718,1656],{},[307,1720,1721],{"class":309,"line":466},[307,1722,1723],{}," userId String\n",[307,1725,1727],{"class":309,"line":1726},18,[307,1728,1729],{}," organizationId String\n",[307,1731,1733],{"class":309,"line":1732},19,[307,1734,1735],{}," role Role @default(MEMBER)\n",[307,1737,1739],{"class":309,"line":1738},20,[307,1740,1741],{}," user User @relation(fields: [userId], references: [id])\n",[307,1743,1745],{"class":309,"line":1744},21,[307,1746,1747],{}," organization Organization @relation(fields: [organizationId], references: [id])\n",[307,1749,1751],{"class":309,"line":1750},22,[307,1752,576],{"emptyLinePlaceholder":127},[307,1754,1756],{"class":309,"line":1755},23,[307,1757,1758],{}," @@unique([userId, organizationId])\n",[307,1760,1762],{"class":309,"line":1761},24,[307,1763,571],{},[20,1765,1766,1767,1770],{},"The ",[304,1768,1769],{},"Membership"," join table is crucial. It allows users to belong to multiple organizations with different roles in each — a common requirement when consultants or agencies use your product across multiple client accounts. It also cleanly separates user identity (authentication) from authorization (what they can do in a specific organization).",[20,1772,1773,1774,1778,1779,1782,1783,1786,1787,1790,1791,1794],{},"This model works with ",[27,1775,1777],{"href":1776},"/blog/prisma-orm-guide","Prisma"," and maps cleanly to your database. The ",[304,1780,1781],{},"@@unique"," constraint on ",[304,1784,1785],{},"userId"," and ",[304,1788,1789],{},"organizationId"," prevents duplicate memberships, and the ",[304,1792,1793],{},"Role"," enum defines the available roles.",[15,1796,1798],{"id":1797},"role-based-access-control","Role-Based Access Control",[20,1800,1801],{},"Start with a simple role hierarchy and expand it only when real requirements demand more granularity. For most SaaS products, three to four roles cover 95% of access control needs:",[20,1803,1804,1807],{},[178,1805,1806],{},"Owner"," has full access including billing, team management, and destructive operations like account deletion. There must always be at least one owner, and ownership transfer should be an explicit action.",[20,1809,1810,1813],{},[178,1811,1812],{},"Admin"," can manage team members, configure settings, and access all features. Admins cannot modify billing or delete the organization. This role is for trusted team leads who manage day-to-day operations.",[20,1815,1816,1819],{},[178,1817,1818],{},"Member"," can use the product's core features — create, read, update, and delete their own work. Members cannot manage team settings or invite new users (or can invite but not assign roles, depending on your product).",[20,1821,1822,1825],{},[178,1823,1824],{},"Viewer"," has read-only access. This role is useful for stakeholders who need visibility without the ability to modify data — clients reviewing project progress, executives checking dashboards, or auditors reviewing records.",[20,1827,1828],{},"Implement role checks at both the API and UI levels. On the backend, middleware validates the user's role in the current organization before processing the request. On the frontend, conditionally render UI elements based on the user's role — do not show the \"Settings\" tab to viewers, do not show the \"Delete\" button to members.",[298,1830,1834],{"className":1831,"code":1832,"language":1833,"meta":105,"style":105},"language-typescript shiki shiki-themes github-dark","function requireRole(...allowedRoles: Role[]) {\n return async (req: Request, res: Response, next: NextFunction) => {\n const membership = await getMembership(req.userId, req.organizationId)\n if (!membership || !allowedRoles.includes(membership.role)) {\n return res.status(403).json({ error: 'Insufficient permissions' })\n }\n next()\n }\n}\n","typescript",[304,1835,1836,1860,1907,1925,1952,1981,1986,1994,1998],{"__ignoreMap":105},[307,1837,1838,1841,1844,1846,1849,1852,1854,1857],{"class":309,"line":310},[307,1839,1840],{"class":519},"function",[307,1842,1843],{"class":523}," requireRole",[307,1845,858],{"class":317},[307,1847,1848],{"class":519},"...",[307,1850,1851],{"class":532},"allowedRoles",[307,1853,536],{"class":519},[307,1855,1856],{"class":523}," Role",[307,1858,1859],{"class":317},"[]) {\n",[307,1861,1862,1864,1867,1870,1873,1875,1878,1881,1884,1886,1889,1891,1894,1896,1899,1902,1905],{"class":309,"line":109},[307,1863,680],{"class":519},[307,1865,1866],{"class":519}," async",[307,1868,1869],{"class":317}," (",[307,1871,1872],{"class":532},"req",[307,1874,536],{"class":519},[307,1876,1877],{"class":523}," Request",[307,1879,1880],{"class":317},", ",[307,1882,1883],{"class":532},"res",[307,1885,536],{"class":519},[307,1887,1888],{"class":523}," Response",[307,1890,1880],{"class":317},[307,1892,1893],{"class":532},"next",[307,1895,536],{"class":519},[307,1897,1898],{"class":523}," NextFunction",[307,1900,1901],{"class":317},") ",[307,1903,1904],{"class":519},"=>",[307,1906,527],{"class":317},[307,1908,1909,1911,1914,1916,1919,1922],{"class":309,"line":106},[307,1910,592],{"class":519},[307,1912,1913],{"class":434}," membership",[307,1915,598],{"class":519},[307,1917,1918],{"class":519}," await",[307,1920,1921],{"class":523}," getMembership",[307,1923,1924],{"class":317},"(req.userId, req.organizationId)\n",[307,1926,1927,1929,1931,1934,1937,1940,1943,1946,1949],{"class":309,"line":343},[307,1928,629],{"class":519},[307,1930,1869],{"class":317},[307,1932,1933],{"class":519},"!",[307,1935,1936],{"class":317},"membership ",[307,1938,1939],{"class":519},"||",[307,1941,1942],{"class":519}," !",[307,1944,1945],{"class":317},"allowedRoles.",[307,1947,1948],{"class":523},"includes",[307,1950,1951],{"class":317},"(membership.role)) {\n",[307,1953,1954,1956,1959,1962,1964,1967,1969,1972,1975,1978],{"class":309,"line":266},[307,1955,680],{"class":519},[307,1957,1958],{"class":317}," res.",[307,1960,1961],{"class":523},"status",[307,1963,858],{"class":317},[307,1965,1966],{"class":434},"403",[307,1968,1191],{"class":317},[307,1970,1971],{"class":523},"json",[307,1973,1974],{"class":317},"({ error: ",[307,1976,1977],{"class":321},"'Insufficient permissions'",[307,1979,1980],{"class":317}," })\n",[307,1982,1983],{"class":309,"line":361},[307,1984,1985],{"class":317}," }\n",[307,1987,1988,1991],{"class":309,"line":369},[307,1989,1990],{"class":523}," next",[307,1992,1993],{"class":317},"()\n",[307,1995,1996],{"class":309,"line":378},[307,1997,1985],{"class":317},[307,1999,2000],{"class":309,"line":129},[307,2001,571],{"class":317},[20,2003,2004],{},"Never rely on frontend-only permission checks. A user who cannot see a button can still call the API directly. Backend enforcement is the security boundary; frontend enforcement is the user experience.",[15,2006,2008],{"id":2007},"invitation-and-onboarding-flow","Invitation and Onboarding Flow",[20,2010,2011],{},"Team growth happens through invitations. The invitation flow affects both security and user experience.",[20,2013,2014],{},"When an admin invites a new member, create an invitation record with the recipient's email, the assigned role, an expiration timestamp, and a unique token. Send an email with a link containing the token.",[20,2016,2017],{},"When the recipient clicks the link, check whether they already have an account. If yes, add them to the organization with the invited role. If no, guide them through account creation and then add them. This fork in the flow is important — existing users should not have to re-register, and new users should create an account as part of accepting the invitation.",[20,2019,2020],{},"Set invitation expiration to 7 days. Expired invitations should show a clear message and offer to request a new invitation. Allow admins to revoke pending invitations and resend them.",[20,2022,2023],{},"Handle edge cases: What if someone is invited to an organization they already belong to? Show a message that they are already a member. What if they are invited with a different email than their account? Let them accept with their existing account or suggest they use the invited email.",[20,2025,2026,2027,2031,2032,2035,2036,2039],{},"For ",[27,2028,2030],{"href":2029},"/blog/authentication-security-guide","enterprise authentication"," scenarios, support domain-based auto-join. If an organization verifies they own ",[304,2033,2034],{},"company.com",", any user who signs up with a ",[304,2037,2038],{},"@company.com"," email can automatically join the organization. This simplifies onboarding for large teams.",[15,2041,2043],{"id":2042},"advanced-permission-patterns","Advanced Permission Patterns",[20,2045,2046],{},"As your product matures, simple role-based access may not be sufficient. Two patterns extend the basic model.",[20,2048,2049,2052],{},[178,2050,2051],{},"Resource-level permissions"," control access to specific objects, not just actions. A user might have member access to the organization but be assigned as the owner of specific projects within it. This requires a permission check that considers both the user's organization role and their relationship to the specific resource.",[20,2054,2055,2056,2059],{},"Implement this with a ",[304,2057,2058],{},"ResourcePermission"," table that maps users to resources with specific permission levels. Check resource permissions after role permissions — the user must have at least member access to the organization and specific access to the resource.",[20,2061,2062,2065],{},[178,2063,2064],{},"Permission inheritance"," lets permissions cascade through a hierarchy. A workspace contains projects, projects contain tasks. If a user has admin access to a workspace, they implicitly have admin access to all projects and tasks within it. This reduces the number of explicit permission assignments needed.",[20,2067,2068],{},"Build the inheritance logic as a function that walks up the resource hierarchy checking for permissions at each level. Cache the resolved permissions per user session to avoid repeated hierarchy walks on every request.",[20,2070,2071,2072,2076],{},"For the ",[27,2073,2075],{"href":2074},"/blog/multi-tenant-architecture","multi-tenant architecture",", user management intersects with tenant isolation. Every permission check must include the organization context. A user who is an admin in Organization A must not have any access in Organization B, even if the data lives in the same database. The combination of organization scoping and role-based access creates the security model that enterprise customers require.",[20,2078,2079,2080,2084],{},"Keep your permission model as simple as your current customers need. Over-engineering permissions for scenarios you do not have yet creates complexity that slows down feature development. Start with roles, add resource permissions when a customer needs them, and add inheritance when your product hierarchy demands it. Build the ",[27,2081,2083],{"href":2082},"/blog/saas-architecture-patterns","SaaS architecture"," to support progressive permission complexity without requiring database migrations or API changes.",[911,2086,2087],{},"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 .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":105,"searchDepth":106,"depth":106,"links":2089},[2090,2091,2092,2093],{"id":1635,"depth":109,"text":1636},{"id":1797,"depth":109,"text":1798},{"id":2007,"depth":109,"text":2008},{"id":2042,"depth":109,"text":2043},"How to build SaaS user management — role-based access control, team structures, invitations, permission inheritance, and the data model that scales.",[2096,2097],"SaaS user management","role-based access control SaaS",{},"/blog/saas-user-management",{"title":1623,"description":2094},"blog/saas-user-management",[2103,2104,2105],"User Management","RBAC","SaaS","8K0oCYwwi6McpIhAvHioHAC4RaMGimcNvGzEmpJU2PA",{"id":2108,"title":2109,"author":2110,"body":2111,"category":2340,"date":921,"description":2341,"extension":116,"featured":117,"image":118,"keywords":2342,"meta":2346,"navigation":127,"path":2347,"readTime":378,"seo":2348,"stem":2349,"tags":2350,"__hash__":2355},"blog/blog/service-mesh-architecture.md","Service Mesh Architecture: When You Actually Need It",{"name":9,"bio":10},{"type":12,"value":2112,"toc":2332},[2113,2117,2120,2123,2126,2129,2131,2135,2138,2144,2151,2157,2176,2178,2182,2185,2191,2197,2203,2209,2216,2218,2222,2225,2231,2237,2243,2249,2255,2257,2261,2264,2270,2280,2286,2292,2295,2304,2306,2308],[15,2114,2116],{"id":2115},"the-problem-service-meshes-solve","The Problem Service Meshes Solve",[20,2118,2119],{},"When you have a handful of services communicating over a network, the operational concerns — service discovery, load balancing, retries, timeouts, authentication, observability — are manageable within each service. A few library calls, some configuration, and reasonable error handling cover most of what you need.",[20,2121,2122],{},"When you have dozens or hundreds of services, these same concerns become unmanageable at the application level. Every service needs retry logic, but implementing retries differently in Go, TypeScript, and Python services creates inconsistency. Every service needs mutual TLS, but managing certificates across 50 services is a full-time job. Every service needs request tracing, but instrumenting each service individually produces incomplete traces with inconsistent formatting.",[20,2124,2125],{},"A service mesh moves these cross-cutting concerns out of the application and into the infrastructure. It deploys a proxy sidecar alongside each service instance. The proxy handles all inbound and outbound network traffic for the service, applying consistent policies for routing, security, retries, and observability — without any changes to the application code.",[20,2127,2128],{},"The appeal is clear: separation of concerns at the infrastructure level. Application developers focus on business logic. Platform engineers configure traffic policies, security, and observability through the mesh.",[954,2130],{},[15,2132,2134],{"id":2133},"how-a-service-mesh-works","How a Service Mesh Works",[20,2136,2137],{},"The architecture has two planes.",[20,2139,2140,2143],{},[178,2141,2142],{},"The data plane"," consists of proxy sidecars deployed alongside every service instance. Envoy is the most common data plane proxy (used by Istio, Consul Connect, and others). Linkerd uses its own purpose-built proxy. Every network request from a service goes through its local proxy, which applies traffic policies before forwarding the request to the destination service's proxy.",[20,2145,2146,2147,2150],{},"The proxy intercepts all traffic transparently. The application makes an HTTP call to ",[304,2148,2149],{},"http://payment-service/charge"," as if it were a simple service call. The proxy intercepts this call, resolves the destination through service discovery, applies retry and timeout policies, encrypts the traffic with mutual TLS, records latency metrics, and forwards the request. The application doesn't know the proxy exists.",[20,2152,2153,2156],{},[178,2154,2155],{},"The control plane"," manages the proxy configuration. When a platform engineer defines a traffic policy — \"retry failed requests to the payment service up to 3 times with a 100ms delay\" — the control plane distributes this configuration to all relevant proxies. The control plane also manages certificates for mutual TLS, collects telemetry from proxies, and provides the APIs and dashboards for managing the mesh.",[20,2158,2159,2160,2163,2164,2167,2168,2171,2172,2175],{},"The specific capabilities that a service mesh provides are: ",[178,2161,2162],{},"traffic management"," (load balancing, retries, timeouts, circuit breaking, traffic shifting for canary deployments), ",[178,2165,2166],{},"security"," (mutual TLS between all services, authorization policies based on service identity), ",[178,2169,2170],{},"observability"," (request-level metrics, distributed tracing, access logging — all without application instrumentation), and ",[178,2173,2174],{},"resilience"," (automatic retries, circuit breaking, and fault injection for chaos testing).",[954,2177],{},[15,2179,2181],{"id":2180},"when-a-service-mesh-is-the-right-call","When a Service Mesh Is the Right Call",[20,2183,2184],{},"The honest assessment: most applications don't need a service mesh. The complexity and operational overhead are justified only in specific circumstances.",[20,2186,2187,2190],{},[178,2188,2189],{},"You have a large number of services communicating over a network."," If you're running 5-10 services, application-level libraries handle the cross-cutting concerns adequately. The overhead of deploying and managing a mesh — the additional proxy containers, the control plane, the configuration management — exceeds the benefit. At 30+ services, the calculus changes because the inconsistency and maintenance burden of application-level cross-cutting concerns becomes significant.",[20,2192,2193,2196],{},[178,2194,2195],{},"You need consistent security policy enforcement."," If your organization requires mutual TLS between all services and consistent authorization policies, implementing and maintaining this across dozens of services in multiple languages is expensive and error-prone. A service mesh enforces these policies uniformly at the infrastructure level.",[20,2198,2199,2202],{},[178,2200,2201],{},"You need traffic management for deployment strategies."," Canary deployments, blue-green deployments, and A/B testing based on traffic splitting are natively supported by service meshes. If you're doing sophisticated deployment strategies across many services, the mesh provides the traffic routing that makes these strategies practical.",[20,2204,2205,2208],{},[178,2206,2207],{},"You need consistent observability without instrumenting every service."," If your services are written in multiple languages and frameworks, instrumenting each for distributed tracing and consistent metrics is a significant effort. The mesh proxy collects this data automatically for all services regardless of their implementation language.",[20,2210,1766,2211,2215],{},[27,2212,2214],{"href":2213},"/blog/microservices-vs-monolith","microservices vs. Monolith"," decision should come well before the service mesh decision. If you're still debating whether to decompose your monolith, you don't need a service mesh. Solve the architectural question first.",[954,2217],{},[15,2219,2221],{"id":2220},"the-operational-cost-be-honest-about-it","The Operational Cost: Be Honest About It",[20,2223,2224],{},"A service mesh is not free. The costs are concrete and ongoing.",[20,2226,2227,2230],{},[178,2228,2229],{},"Resource overhead."," Every service instance gets a proxy sidecar. For a system with 100 service instances, that's 100 additional containers consuming CPU and memory. Envoy typically uses 50-100MB of memory per sidecar. Across a large deployment, this adds up to meaningful infrastructure cost.",[20,2232,2233,2236],{},[178,2234,2235],{},"Latency overhead."," Every request passes through two proxies — one on the source side and one on the destination side. Each proxy adds a small amount of latency (typically 1-3ms). For most applications, this is negligible. For latency-sensitive paths where every millisecond matters, the overhead needs to be measured and accounted for.",[20,2238,2239,2242],{},[178,2240,2241],{},"Operational complexity."," The control plane is a critical piece of infrastructure. If the control plane goes down, proxy configuration updates stop. Certificate rotation fails. New service instances can't join the mesh. You need to operate the mesh with the same rigor as any other critical infrastructure — monitoring, alerting, capacity planning, upgrade procedures.",[20,2244,2245,2248],{},[178,2246,2247],{},"Debugging complexity."," When something goes wrong in a meshed environment, the proxy layer adds a dimension to debugging. Is the 500 error coming from the application, from the proxy, or from a policy misconfiguration? Request tracing helps, but understanding the mesh's behavior adds cognitive load during incidents.",[20,2250,2251,2254],{},[178,2252,2253],{},"Upgrade path."," Service mesh platforms release frequently. Upgrades may require coordination between control plane and data plane versions. Falling behind on upgrades means missing security patches. Keeping up means regularly testing and rolling out infrastructure changes across your entire service fleet.",[954,2256],{},[15,2258,2260],{"id":2259},"alternatives-that-cover-most-of-the-ground","Alternatives That Cover Most of the Ground",[20,2262,2263],{},"For many of the problems a service mesh solves, there are simpler alternatives that provide most of the benefit with less operational overhead.",[20,2265,2266,2269],{},[178,2267,2268],{},"Application libraries"," like gRPC (which includes load balancing, retries, and deadline propagation) or purpose-built service communication libraries provide per-language implementations of resilience patterns. The downside is inconsistency across languages and maintenance burden per service, but for small to medium service counts, this is manageable.",[20,2271,2272,2275,2276,2279],{},[178,2273,2274],{},"API gateways"," handle traffic management, authentication, and rate limiting at the edge of your service network. For architectures where most traffic flows through a single entry point, an API gateway provides meaningful traffic management without a full mesh. The patterns in ",[27,2277,1599],{"href":2278},"/blog/api-design-best-practices"," apply to gateway-mediated communication.",[20,2281,2282,2285],{},[178,2283,2284],{},"Infrastructure-level mTLS"," can be achieved through tools like SPIFFE/SPIRE for identity and certificate management without a full service mesh. If your primary requirement is service-to-service encryption and authentication, this is a lighter-weight approach.",[20,2287,2288,2291],{},[178,2289,2290],{},"Distributed tracing with OpenTelemetry"," provides observability through application instrumentation. It requires per-service setup, but the OpenTelemetry SDK supports most languages and the instrumentation cost is a one-time effort per service.",[20,2293,2294],{},"The pragmatic path for most organizations: start with application libraries and an API gateway. When the service count grows to the point where maintaining consistency across services is consuming significant engineering time, evaluate a service mesh. Start with a lightweight mesh like Linkerd rather than a feature-rich but complex mesh like Istio, and expand capabilities as needed.",[20,2296,2297,2298],{},"If you're evaluating whether your architecture needs a service mesh, ",[27,2299,2303],{"href":2300,"rel":2301},"https://calendly.com/jamesrossjr",[2302],"nofollow","let's discuss the tradeoffs for your specific situation.",[954,2305],{},[15,2307,1111],{"id":1110},[1113,2309,2310,2315,2321,2326],{},[713,2311,2312],{},[27,2313,2314],{"href":2213},"Microservices vs Monolith: The Honest Trade-off Analysis",[713,2316,2317],{},[27,2318,2320],{"href":2319},"/blog/distributed-systems-fundamentals","Distributed Systems Fundamentals: What Every Developer Should Know",[713,2322,2323],{},[27,2324,2325],{"href":2278},"API Design Best Practices for Production Systems",[713,2327,2328],{},[27,2329,2331],{"href":2330},"/blog/software-architecture-patterns","Software Architecture Patterns for Modern Applications",{"title":105,"searchDepth":106,"depth":106,"links":2333},[2334,2335,2336,2337,2338,2339],{"id":2115,"depth":109,"text":2116},{"id":2133,"depth":109,"text":2134},{"id":2180,"depth":109,"text":2181},{"id":2220,"depth":109,"text":2221},{"id":2259,"depth":109,"text":2260},{"id":1110,"depth":109,"text":1111},"Architecture","Service meshes solve real problems in complex microservice deployments, but they add operational weight that most systems don't need. Here's an honest assessment.",[2343,2344,2345],"service mesh architecture","when to use service mesh","Istio Linkerd comparison",{},"/blog/service-mesh-architecture",{"title":2109,"description":2341},"blog/service-mesh-architecture",[2351,2352,2353,2354],"Service Mesh","Microservices","Infrastructure","Distributed Systems","u9o4fNyVJl9nBEs_ALHD6fdsohlpo5SIHjl1CDA1CB4",{"id":2357,"title":2358,"author":2359,"body":2360,"category":1607,"date":921,"description":3228,"extension":116,"featured":117,"image":118,"keywords":3229,"meta":3232,"navigation":127,"path":3233,"readTime":369,"seo":3234,"stem":3235,"tags":3236,"__hash__":3240},"blog/blog/service-workers-offline-first.md","Service Workers and Offline-First Web Applications",{"name":9,"bio":10},{"type":12,"value":2361,"toc":3222},[2362,2366,2369,2372,2375,2382,2384,2388,2391,2397,2446,2452,2583,2589,2742,2752,2759,2761,2765,2768,2774,2780,2786,2979,2987,2989,2993,2996,2999,3206,3209,3216,3219],[15,2363,2365],{"id":2364},"why-offline-matters-even-when-users-are-online","Why Offline Matters Even When Users Are Online",[20,2367,2368],{},"The argument for offline-first web applications usually centers on connectivity dead zones — subway tunnels, rural areas, airplane mode. Those scenarios matter, but they represent a small fraction of the value that service workers and offline patterns provide.",[20,2370,2371],{},"The more compelling case is unreliable connectivity. Users on a 4G connection might experience intermittent packet loss that makes API calls randomly fail. A conference venue with 3000 people on the same WiFi network provides theoretically online but practically unusable connectivity. A user's train passes through a tunnel for 30 seconds, and the form they just submitted disappears into the void.",[20,2373,2374],{},"Offline-first architecture treats the network as an enhancement rather than a requirement. The application loads instantly from a local cache regardless of network state. User actions are stored locally and synchronized when connectivity is available. The UI always responds immediately, even if the underlying network request has not completed. This produces an experience that is fast and resilient for every user, not just those without connectivity.",[20,2376,2377,2378,1036],{},"The technical foundation for this architecture is the service worker — a JavaScript file that runs in a background thread, separate from the main page. Service workers intercept network requests, manage a programmatic cache, and continue running even when the user navigates away from the page. They are the mechanism behind push notifications, background sync, and the caching strategies that enable offline functionality in ",[27,2379,2381],{"href":2380},"/blog/progressive-web-apps-guide","progressive web apps",[954,2383],{},[15,2385,2387],{"id":2386},"service-worker-lifecycle","Service Worker Lifecycle",[20,2389,2390],{},"Understanding the service worker lifecycle is essential because it differs fundamentally from regular JavaScript. A service worker is not a script that runs when you include it in your HTML. It is a persistent background process with its own installation, activation, and update phases.",[20,2392,2393,2396],{},[178,2394,2395],{},"Registration"," happens from your main application JavaScript:",[298,2398,2402],{"className":2399,"code":2400,"language":2401,"meta":105,"style":105},"language-javascript shiki shiki-themes github-dark","if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/sw.js', { scope: '/' });\n}\n","javascript",[304,2403,2404,2420,2442],{"__ignoreMap":105},[307,2405,2406,2409,2411,2414,2417],{"class":309,"line":310},[307,2407,2408],{"class":519},"if",[307,2410,1869],{"class":317},[307,2412,2413],{"class":321},"'serviceWorker'",[307,2415,2416],{"class":519}," in",[307,2418,2419],{"class":317}," navigator) {\n",[307,2421,2422,2425,2428,2430,2433,2436,2439],{"class":309,"line":109},[307,2423,2424],{"class":317}," navigator.serviceWorker.",[307,2426,2427],{"class":523},"register",[307,2429,858],{"class":317},[307,2431,2432],{"class":321},"'/sw.js'",[307,2434,2435],{"class":317},", { scope: ",[307,2437,2438],{"class":321},"'/'",[307,2440,2441],{"class":317}," });\n",[307,2443,2444],{"class":309,"line":106},[307,2445,571],{"class":317},[20,2447,2448,2451],{},[178,2449,2450],{},"Installation"," fires the first time the browser sees a new or changed service worker file. This is where you pre-cache your application shell — the HTML, CSS, JavaScript, and assets needed to render the application without any network requests:",[298,2453,2455],{"className":2399,"code":2454,"language":2401,"meta":105,"style":105},"self.addEventListener('install', (event) => {\n event.waitUntil(\n caches.open('app-shell-v1').then((cache) => {\n return cache.addAll([\n '/',\n '/styles/main.css',\n '/scripts/app.js',\n '/images/logo.svg',\n ]);\n })\n );\n});\n",[304,2456,2457,2482,2493,2523,2536,2543,2550,2557,2564,2569,2573,2578],{"__ignoreMap":105},[307,2458,2459,2462,2465,2467,2470,2473,2476,2478,2480],{"class":309,"line":310},[307,2460,2461],{"class":317},"self.",[307,2463,2464],{"class":523},"addEventListener",[307,2466,858],{"class":317},[307,2468,2469],{"class":321},"'install'",[307,2471,2472],{"class":317},", (",[307,2474,2475],{"class":532},"event",[307,2477,1901],{"class":317},[307,2479,1904],{"class":519},[307,2481,527],{"class":317},[307,2483,2484,2487,2490],{"class":309,"line":109},[307,2485,2486],{"class":317}," event.",[307,2488,2489],{"class":523},"waitUntil",[307,2491,2492],{"class":317},"(\n",[307,2494,2495,2498,2501,2503,2506,2508,2511,2514,2517,2519,2521],{"class":309,"line":106},[307,2496,2497],{"class":317}," caches.",[307,2499,2500],{"class":523},"open",[307,2502,858],{"class":317},[307,2504,2505],{"class":321},"'app-shell-v1'",[307,2507,1191],{"class":317},[307,2509,2510],{"class":523},"then",[307,2512,2513],{"class":317},"((",[307,2515,2516],{"class":532},"cache",[307,2518,1901],{"class":317},[307,2520,1904],{"class":519},[307,2522,527],{"class":317},[307,2524,2525,2527,2530,2533],{"class":309,"line":343},[307,2526,680],{"class":519},[307,2528,2529],{"class":317}," cache.",[307,2531,2532],{"class":523},"addAll",[307,2534,2535],{"class":317},"([\n",[307,2537,2538,2541],{"class":309,"line":266},[307,2539,2540],{"class":321}," '/'",[307,2542,875],{"class":317},[307,2544,2545,2548],{"class":309,"line":361},[307,2546,2547],{"class":321}," '/styles/main.css'",[307,2549,875],{"class":317},[307,2551,2552,2555],{"class":309,"line":369},[307,2553,2554],{"class":321}," '/scripts/app.js'",[307,2556,875],{"class":317},[307,2558,2559,2562],{"class":309,"line":378},[307,2560,2561],{"class":321}," '/images/logo.svg'",[307,2563,875],{"class":317},[307,2565,2566],{"class":309,"line":129},[307,2567,2568],{"class":317}," ]);\n",[307,2570,2571],{"class":309,"line":395},[307,2572,1980],{"class":317},[307,2574,2575],{"class":309,"line":405},[307,2576,2577],{"class":317}," );\n",[307,2579,2580],{"class":309,"line":415},[307,2581,2582],{"class":317},"});\n",[20,2584,2585,2588],{},[178,2586,2587],{},"Activation"," fires after installation, when the service worker takes control of the pages in its scope. This is where you clean up old caches from previous versions:",[298,2590,2592],{"className":2399,"code":2591,"language":2401,"meta":105,"style":105},"self.addEventListener('activate', (event) => {\n event.waitUntil(\n caches.keys().then((keys) => {\n return Promise.all(\n keys\n .filter((key) => key !== 'app-shell-v1' && key !== 'api-cache-v1')\n .map((key) => caches.delete(key))\n );\n })\n );\n});\n",[304,2593,2594,2615,2623,2645,2659,2664,2703,2726,2730,2734,2738],{"__ignoreMap":105},[307,2595,2596,2598,2600,2602,2605,2607,2609,2611,2613],{"class":309,"line":310},[307,2597,2461],{"class":317},[307,2599,2464],{"class":523},[307,2601,858],{"class":317},[307,2603,2604],{"class":321},"'activate'",[307,2606,2472],{"class":317},[307,2608,2475],{"class":532},[307,2610,1901],{"class":317},[307,2612,1904],{"class":519},[307,2614,527],{"class":317},[307,2616,2617,2619,2621],{"class":309,"line":109},[307,2618,2486],{"class":317},[307,2620,2489],{"class":523},[307,2622,2492],{"class":317},[307,2624,2625,2627,2630,2633,2635,2637,2639,2641,2643],{"class":309,"line":106},[307,2626,2497],{"class":317},[307,2628,2629],{"class":523},"keys",[307,2631,2632],{"class":317},"().",[307,2634,2510],{"class":523},[307,2636,2513],{"class":317},[307,2638,2629],{"class":532},[307,2640,1901],{"class":317},[307,2642,1904],{"class":519},[307,2644,527],{"class":317},[307,2646,2647,2649,2652,2654,2657],{"class":309,"line":343},[307,2648,680],{"class":519},[307,2650,2651],{"class":434}," Promise",[307,2653,1036],{"class":317},[307,2655,2656],{"class":523},"all",[307,2658,2492],{"class":317},[307,2660,2661],{"class":309,"line":266},[307,2662,2663],{"class":317}," keys\n",[307,2665,2666,2669,2672,2674,2677,2679,2681,2684,2687,2690,2693,2695,2697,2700],{"class":309,"line":361},[307,2667,2668],{"class":317}," .",[307,2670,2671],{"class":523},"filter",[307,2673,2513],{"class":317},[307,2675,2676],{"class":532},"key",[307,2678,1901],{"class":317},[307,2680,1904],{"class":519},[307,2682,2683],{"class":317}," key ",[307,2685,2686],{"class":519},"!==",[307,2688,2689],{"class":321}," 'app-shell-v1'",[307,2691,2692],{"class":519}," &&",[307,2694,2683],{"class":317},[307,2696,2686],{"class":519},[307,2698,2699],{"class":321}," 'api-cache-v1'",[307,2701,2702],{"class":317},")\n",[307,2704,2705,2707,2710,2712,2714,2716,2718,2720,2723],{"class":309,"line":369},[307,2706,2668],{"class":317},[307,2708,2709],{"class":523},"map",[307,2711,2513],{"class":317},[307,2713,2676],{"class":532},[307,2715,1901],{"class":317},[307,2717,1904],{"class":519},[307,2719,2497],{"class":317},[307,2721,2722],{"class":523},"delete",[307,2724,2725],{"class":317},"(key))\n",[307,2727,2728],{"class":309,"line":378},[307,2729,2577],{"class":317},[307,2731,2732],{"class":309,"line":129},[307,2733,1980],{"class":317},[307,2735,2736],{"class":309,"line":395},[307,2737,2577],{"class":317},[307,2739,2740],{"class":309,"line":405},[307,2741,2582],{"class":317},[20,2743,2744,2747,2748,2751],{},[178,2745,2746],{},"Fetch interception"," is where the service worker earns its keep. Every network request from pages within the service worker's scope passes through the ",[304,2749,2750],{},"fetch"," event handler, where you decide whether to serve from cache, from network, or from a combination of both.",[20,2753,2754,2755,2758],{},"The critical detail: a new service worker installs but does not activate until all tabs running the old version are closed. This prevents the new version from breaking active sessions. You can force immediate activation with ",[304,2756,2757],{},"self.skipWaiting()",", but use this carefully — it can cause issues if the new service worker expects cached assets that the old version did not pre-cache.",[954,2760],{},[15,2762,2764],{"id":2763},"caching-strategies-for-real-applications","Caching Strategies for Real Applications",[20,2766,2767],{},"The caching strategy you choose determines how your application balances freshness against speed. There is no single correct strategy — different resources warrant different approaches.",[20,2769,2770,2773],{},[178,2771,2772],{},"Cache First"," serves from cache if available, falling back to network only on cache miss. Best for static assets with content-hashed filenames (JS, CSS, images). These files never change — the filename changes when the content changes, so the cached version is always correct.",[20,2775,2776,2779],{},[178,2777,2778],{},"Network First"," tries the network and falls back to cache if the network fails. Best for API responses and HTML documents where freshness matters. This provides the latest data when online and cached data when offline.",[20,2781,2782,2785],{},[178,2783,2784],{},"Stale While Revalidate"," serves from cache immediately and updates the cache from the network in the background. Best for data that changes periodically but where serving slightly stale data is acceptable — user profiles, product listings, article content. The user gets an instant response, and the next request gets fresh data.",[298,2787,2789],{"className":2399,"code":2788,"language":2401,"meta":105,"style":105},"self.addEventListener('fetch', (event) => {\n if (event.request.url.includes('/api/')) {\n // Stale while revalidate for API calls\n event.respondWith(\n caches.open('api-cache-v1').then((cache) => {\n return cache.match(event.request).then((cachedResponse) => {\n const networkFetch = fetch(event.request).then((networkResponse) => {\n cache.put(event.request, networkResponse.clone());\n return networkResponse;\n });\n return cachedResponse || networkFetch;\n });\n })\n );\n }\n});\n",[304,2790,2791,2812,2829,2834,2843,2868,2893,2920,2936,2943,2947,2959,2963,2967,2971,2975],{"__ignoreMap":105},[307,2792,2793,2795,2797,2799,2802,2804,2806,2808,2810],{"class":309,"line":310},[307,2794,2461],{"class":317},[307,2796,2464],{"class":523},[307,2798,858],{"class":317},[307,2800,2801],{"class":321},"'fetch'",[307,2803,2472],{"class":317},[307,2805,2475],{"class":532},[307,2807,1901],{"class":317},[307,2809,1904],{"class":519},[307,2811,527],{"class":317},[307,2813,2814,2816,2819,2821,2823,2826],{"class":309,"line":109},[307,2815,629],{"class":519},[307,2817,2818],{"class":317}," (event.request.url.",[307,2820,1948],{"class":523},[307,2822,858],{"class":317},[307,2824,2825],{"class":321},"'/api/'",[307,2827,2828],{"class":317},")) {\n",[307,2830,2831],{"class":309,"line":106},[307,2832,2833],{"class":604}," // Stale while revalidate for API calls\n",[307,2835,2836,2838,2841],{"class":309,"line":343},[307,2837,2486],{"class":317},[307,2839,2840],{"class":523},"respondWith",[307,2842,2492],{"class":317},[307,2844,2845,2847,2849,2851,2854,2856,2858,2860,2862,2864,2866],{"class":309,"line":266},[307,2846,2497],{"class":317},[307,2848,2500],{"class":523},[307,2850,858],{"class":317},[307,2852,2853],{"class":321},"'api-cache-v1'",[307,2855,1191],{"class":317},[307,2857,2510],{"class":523},[307,2859,2513],{"class":317},[307,2861,2516],{"class":532},[307,2863,1901],{"class":317},[307,2865,1904],{"class":519},[307,2867,527],{"class":317},[307,2869,2870,2872,2874,2877,2880,2882,2884,2887,2889,2891],{"class":309,"line":361},[307,2871,680],{"class":519},[307,2873,2529],{"class":317},[307,2875,2876],{"class":523},"match",[307,2878,2879],{"class":317},"(event.request).",[307,2881,2510],{"class":523},[307,2883,2513],{"class":317},[307,2885,2886],{"class":532},"cachedResponse",[307,2888,1901],{"class":317},[307,2890,1904],{"class":519},[307,2892,527],{"class":317},[307,2894,2895,2897,2900,2902,2905,2907,2909,2911,2914,2916,2918],{"class":309,"line":369},[307,2896,592],{"class":519},[307,2898,2899],{"class":434}," networkFetch",[307,2901,598],{"class":519},[307,2903,2904],{"class":523}," fetch",[307,2906,2879],{"class":317},[307,2908,2510],{"class":523},[307,2910,2513],{"class":317},[307,2912,2913],{"class":532},"networkResponse",[307,2915,1901],{"class":317},[307,2917,1904],{"class":519},[307,2919,527],{"class":317},[307,2921,2922,2924,2927,2930,2933],{"class":309,"line":378},[307,2923,2529],{"class":317},[307,2925,2926],{"class":523},"put",[307,2928,2929],{"class":317},"(event.request, networkResponse.",[307,2931,2932],{"class":523},"clone",[307,2934,2935],{"class":317},"());\n",[307,2937,2938,2940],{"class":309,"line":129},[307,2939,680],{"class":519},[307,2941,2942],{"class":317}," networkResponse;\n",[307,2944,2945],{"class":309,"line":395},[307,2946,2441],{"class":317},[307,2948,2949,2951,2954,2956],{"class":309,"line":405},[307,2950,680],{"class":519},[307,2952,2953],{"class":317}," cachedResponse ",[307,2955,1939],{"class":519},[307,2957,2958],{"class":317}," networkFetch;\n",[307,2960,2961],{"class":309,"line":415},[307,2962,2441],{"class":317},[307,2964,2965],{"class":309,"line":426},[307,2966,1980],{"class":317},[307,2968,2969],{"class":309,"line":438},[307,2970,2577],{"class":317},[307,2972,2973],{"class":309,"line":447},[307,2974,1985],{"class":317},[307,2976,2977],{"class":309,"line":456},[307,2978,2582],{"class":317},[20,2980,2981,2982,2986],{},"For frameworks like Nuxt, the ",[27,2983,2985],{"href":2984},"/blog/nuxt-performance-optimization","PWA module"," generates service workers with configurable caching strategies, saving you from writing raw service worker code. Workbox (by Google) is the standard library for production service workers — it provides pre-built caching strategies, precaching with revision management, and routing patterns that handle the common cases robustly.",[954,2988],{},[15,2990,2992],{"id":2991},"background-sync-and-offline-actions","Background Sync and Offline Actions",[20,2994,2995],{},"Caching solves the read side of offline — users can view content without connectivity. Background sync solves the write side — users can submit forms, update records, and create content while offline, with the changes synchronized when connectivity returns.",[20,2997,2998],{},"The Background Sync API lets a service worker defer a network request until connectivity is available:",[298,3000,3002],{"className":2399,"code":3001,"language":2401,"meta":105,"style":105},"// In your application code\nasync function submitForm(data) {\n try {\n await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });\n } catch {\n // Network failed — queue for background sync\n const registration = await navigator.serviceWorker.ready;\n await registration.sync.register('submit-form');\n // Store data in IndexedDB for the service worker to access\n await saveToIndexedDB('pending-submissions', data);\n }\n}\n\n// In the service worker\nself.addEventListener('sync', (event) => {\n if (event.tag === 'submit-form') {\n event.waitUntil(processPendingSubmissions());\n }\n});\n",[304,3003,3004,3009,3028,3035,3066,3076,3081,3095,3112,3117,3132,3136,3140,3144,3149,3170,3185,3198,3202],{"__ignoreMap":105},[307,3005,3006],{"class":309,"line":310},[307,3007,3008],{"class":604},"// In your application code\n",[307,3010,3011,3014,3017,3020,3022,3025],{"class":309,"line":109},[307,3012,3013],{"class":519},"async",[307,3015,3016],{"class":519}," function",[307,3018,3019],{"class":523}," submitForm",[307,3021,858],{"class":317},[307,3023,3024],{"class":532},"data",[307,3026,3027],{"class":317},") {\n",[307,3029,3030,3033],{"class":309,"line":106},[307,3031,3032],{"class":519}," try",[307,3034,527],{"class":317},[307,3036,3037,3039,3041,3043,3046,3049,3052,3055,3058,3060,3063],{"class":309,"line":343},[307,3038,1918],{"class":519},[307,3040,2904],{"class":523},[307,3042,858],{"class":317},[307,3044,3045],{"class":321},"'/api/submit'",[307,3047,3048],{"class":317},", { method: ",[307,3050,3051],{"class":321},"'POST'",[307,3053,3054],{"class":317},", body: ",[307,3056,3057],{"class":434},"JSON",[307,3059,1036],{"class":317},[307,3061,3062],{"class":523},"stringify",[307,3064,3065],{"class":317},"(data) });\n",[307,3067,3068,3071,3074],{"class":309,"line":266},[307,3069,3070],{"class":317}," } ",[307,3072,3073],{"class":519},"catch",[307,3075,527],{"class":317},[307,3077,3078],{"class":309,"line":361},[307,3079,3080],{"class":604}," // Network failed — queue for background sync\n",[307,3082,3083,3085,3088,3090,3092],{"class":309,"line":369},[307,3084,592],{"class":519},[307,3086,3087],{"class":434}," registration",[307,3089,598],{"class":519},[307,3091,1918],{"class":519},[307,3093,3094],{"class":317}," navigator.serviceWorker.ready;\n",[307,3096,3097,3099,3102,3104,3106,3109],{"class":309,"line":378},[307,3098,1918],{"class":519},[307,3100,3101],{"class":317}," registration.sync.",[307,3103,2427],{"class":523},[307,3105,858],{"class":317},[307,3107,3108],{"class":321},"'submit-form'",[307,3110,3111],{"class":317},");\n",[307,3113,3114],{"class":309,"line":129},[307,3115,3116],{"class":604}," // Store data in IndexedDB for the service worker to access\n",[307,3118,3119,3121,3124,3126,3129],{"class":309,"line":395},[307,3120,1918],{"class":519},[307,3122,3123],{"class":523}," saveToIndexedDB",[307,3125,858],{"class":317},[307,3127,3128],{"class":321},"'pending-submissions'",[307,3130,3131],{"class":317},", data);\n",[307,3133,3134],{"class":309,"line":405},[307,3135,1985],{"class":317},[307,3137,3138],{"class":309,"line":415},[307,3139,571],{"class":317},[307,3141,3142],{"class":309,"line":426},[307,3143,576],{"emptyLinePlaceholder":127},[307,3145,3146],{"class":309,"line":438},[307,3147,3148],{"class":604},"// In the service worker\n",[307,3150,3151,3153,3155,3157,3160,3162,3164,3166,3168],{"class":309,"line":447},[307,3152,2461],{"class":317},[307,3154,2464],{"class":523},[307,3156,858],{"class":317},[307,3158,3159],{"class":321},"'sync'",[307,3161,2472],{"class":317},[307,3163,2475],{"class":532},[307,3165,1901],{"class":317},[307,3167,1904],{"class":519},[307,3169,527],{"class":317},[307,3171,3172,3174,3177,3180,3183],{"class":309,"line":456},[307,3173,629],{"class":519},[307,3175,3176],{"class":317}," (event.tag ",[307,3178,3179],{"class":519},"===",[307,3181,3182],{"class":321}," 'submit-form'",[307,3184,3027],{"class":317},[307,3186,3187,3189,3191,3193,3196],{"class":309,"line":466},[307,3188,2486],{"class":317},[307,3190,2489],{"class":523},[307,3192,858],{"class":317},[307,3194,3195],{"class":523},"processPendingSubmissions",[307,3197,2935],{"class":317},[307,3199,3200],{"class":309,"line":1726},[307,3201,1985],{"class":317},[307,3203,3204],{"class":309,"line":1732},[307,3205,2582],{"class":317},[20,3207,3208],{},"The user experience requires clear communication. When an action is queued for sync, show a status indicator: \"Saved locally — will sync when online.\" When sync completes, update the indicator: \"Synced.\" If sync fails repeatedly, notify the user rather than silently losing data.",[20,3210,3211,3212,3215],{},"Conflict resolution is the hard problem in offline sync. If two users edit the same record offline and both sync when they reconnect, whose changes win? For simple applications, last-write-wins is sufficient. For collaborative applications, you need conflict detection and resolution — either automatic merging or presenting the conflict to the user. This is a ",[27,3213,3214],{"href":2278},"backend architecture concern"," as much as a frontend concern, and the strategy must be defined before building the sync mechanism.",[20,3217,3218],{},"Build offline features incrementally. Start by caching the app shell for instant loading. Then add caching for read data. Then add background sync for writes. Each layer adds value independently, and you can ship each one as a meaningful improvement rather than waiting for a complete offline-first rewrite.",[911,3220,3221],{},"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 .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 .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}",{"title":105,"searchDepth":106,"depth":106,"links":3223},[3224,3225,3226,3227],{"id":2364,"depth":109,"text":2365},{"id":2386,"depth":109,"text":2387},{"id":2763,"depth":109,"text":2764},{"id":2991,"depth":109,"text":2992},"Service workers enable offline functionality, background sync, and push notifications. Here's how to implement offline-first patterns that actually work in production.",[3230,3231],"service workers offline first","offline web applications",{},"/blog/service-workers-offline-first",{"title":2358,"description":3228},"blog/service-workers-offline-first",[3237,3238,3239],"Service Workers","PWA","Offline","wOQ6XY8GcExWC_zR80YUYwN04WmtoHqzBhFVFBj1csw",{"id":3242,"title":3243,"author":3244,"body":3245,"category":113,"date":3329,"description":3330,"extension":116,"featured":117,"image":118,"keywords":3331,"meta":3337,"navigation":127,"path":3338,"readTime":369,"seo":3339,"stem":3340,"tags":3341,"__hash__":3347},"blog/blog/round-towers-ireland.md","Round Towers of Ireland: Purpose, Design, and Mystery",{"name":9,"bio":10},{"type":12,"value":3246,"toc":3323},[3247,3251,3254,3261,3264,3268,3271,3277,3280,3283,3287,3295,3302,3305,3309,3312,3320],[15,3248,3250],{"id":3249},"stones-that-still-stand","Stones That Still Stand",[20,3252,3253],{},"There are approximately 65 round towers still standing in Ireland, with a handful more in Scotland and on the Isle of Man. They range in height from around 18 meters to over 34 meters, with diameters typically between 4 and 6 meters at the base. They taper slightly as they rise, terminating in conical stone caps. Their most striking feature is the doorway, which is almost always set between two and four meters above ground level, accessible only by a ladder that could be pulled up from inside.",[20,3255,3256,3257,3260],{},"The towers were built between the tenth and twelfth centuries, during the period of intense monastic activity and intermittent Viking raiding that defined early medieval Ireland. They are found almost exclusively at monastic sites -- churches, abbeys, and ecclesiastical settlements -- and they are oriented with their doorways facing the main church building. In Irish, they are called ",[234,3258,3259],{},"cloigtheach"," -- bell houses -- which gives an immediate clue to at least one of their functions.",[20,3262,3263],{},"The construction is remarkable. These are mortared stone structures, built without buttresses or external support, that have stood for a thousand years. The walls are typically about a meter thick at the base, thinning as the tower rises. Interior floors were made of wood, connected by ladders between levels, with small windows at each story. The conical cap is constructed with overlapping stone courses, a technique called corbelling that requires precise engineering.",[15,3265,3267],{"id":3266},"the-purpose-debate","The Purpose Debate",[20,3269,3270],{},"The purpose of Ireland's round towers has been debated since at least the eighteenth century, and the debate has generated some spectacularly wrong answers. Early antiquarians proposed that the towers were fire temples, astronomical observatories, phallic symbols, or remnants of a pre-Celtic civilization. None of these theories survive modern scrutiny.",[20,3272,3273,3274,3276],{},"The scholarly consensus today identifies three overlapping functions. First, the towers served as bell towers -- the ",[234,3275,3259],{}," designation is likely accurate. A bell rung from the top of a 30-meter tower would be audible across the surrounding landscape, calling the monastic community to prayer and marking the canonical hours.",[20,3278,3279],{},"Second, the towers functioned as treasuries. Irish monasteries were wealthy institutions, holding precious manuscripts, metalwork, and relics. The elevated doorway and the ability to pull up the access ladder made the tower a defensible storage space. Several historical accounts describe monks retreating into round towers during Viking raids, pulling their treasures and manuscripts in after them and drawing up the ladder.",[20,3281,3282],{},"Third, the towers served as landmarks and symbols of ecclesiastical prestige. A round tower was a significant investment of labor and skill, and its construction signaled the wealth and importance of the monastic foundation. The tower was visible for miles, marking the site as a center of learning, worship, and community life.",[15,3284,3286],{"id":3285},"builders-and-raiders","Builders and Raiders",[20,3288,3289,3290,3294],{},"The chronology of round tower construction overlaps almost exactly with the ",[27,3291,3293],{"href":3292},"/blog/viking-age-scotland","Viking Age in Ireland",". The earliest towers appear in the late ninth or early tenth century, when Norse raiders had been striking Irish monastic sites for over a hundred years. The defensive interpretation of the towers is strengthened by this timing. The elevated doorway, the thick walls, and the fire-resistant stone construction all make sense as responses to the threat of raiding.",[20,3296,3297,3298,3301],{},"But the towers were not impregnable. The ",[234,3299,3300],{},"Annals of the Four Masters"," and other Irish chronicles record multiple instances of round towers being attacked, burned, or besieged. In 1097, the round tower at Monasterboice was burned with its contents and the people sheltering inside. In 950, the tower at Slane suffered a similar fate. The wooden interior floors and ladders were vulnerable to fire, and a determined attacker could burn out the occupants by setting a fire at the base.",[20,3303,3304],{},"The towers, then, were not fortresses. They were deterrents -- sufficient to discourage a quick smash-and-grab raid but not capable of withstanding a sustained siege. Their value was primarily in buying time: time to hide treasures, time for help to arrive, time for the raiders to decide the effort was not worth it and move on to an easier target.",[15,3306,3308],{"id":3307},"what-survives-and-what-it-means","What Survives and What It Means",[20,3310,3311],{},"The round towers of Ireland are among the best-preserved medieval structures in Europe. Their survival is partly a matter of engineering -- the tapered, mortared design is inherently stable -- and partly a matter of cultural reverence. The towers were respected as sacred structures even after the monasteries around them fell into ruin. They were incorporated into later church buildings, used as landmarks for navigation, and protected by communities that understood them as links to a deep past.",[20,3313,3314,3315,3319],{},"The distribution of round towers maps the geography of early medieval ",[27,3316,3318],{"href":3317},"/blog/celtic-otherworld-beliefs","Irish monasticism",", which was itself an extension of the older Celtic ecclesiastical tradition that had developed in Ireland and western Britain since the fifth century. The monastic sites where round towers stand were not just religious institutions. They were centers of learning, manuscript production, metalworking, and agricultural management. The tower was the most visible element of a complex institutional landscape.",[20,3321,3322],{},"Today, the round towers attract visitors from around the world, drawn by their elegance, their mystery, and their sheer improbability. A thousand-year-old stone tower, standing straight and complete on a hillside in the Irish countryside, is a powerful argument against the assumption that the medieval world was primitive. These were sophisticated structures built by communities that possessed engineering knowledge, aesthetic sensibility, and the organizational capacity to marshal resources for a project that would take years to complete. They are monuments not just to faith but to the civilization that produced them.",{"title":105,"searchDepth":106,"depth":106,"links":3324},[3325,3326,3327,3328],{"id":3249,"depth":109,"text":3250},{"id":3266,"depth":109,"text":3267},{"id":3285,"depth":109,"text":3286},{"id":3307,"depth":109,"text":3308},"2025-12-22","Ireland's round towers are among the most distinctive architectural features of the medieval landscape -- slender stone columns rising from monastic sites, their doorways set high above the ground. Their purpose has been debated for centuries.",[3332,3333,3334,3335,3336],"round towers ireland","irish round towers purpose","cloigtheach bell tower","monastic round towers","round tower design",{},"/blog/round-towers-ireland",{"title":3243,"description":3330},"blog/round-towers-ireland",[3342,3343,3344,3345,3346],"Round Towers","Irish Architecture","Medieval Ireland","Monastic Ireland","Celtic Heritage","-wJNgApCErBt1_nSB2Fw9UbqdHbIdhQFnXfEs1x1_kE",{"id":3349,"title":3350,"author":3351,"body":3352,"category":1607,"date":3329,"description":3478,"extension":116,"featured":117,"image":118,"keywords":3479,"meta":3483,"navigation":127,"path":3484,"readTime":369,"seo":3485,"stem":3486,"tags":3487,"__hash__":3492},"blog/blog/routiine-io-stripe-billing.md","Implementing Multi-Tier Stripe Billing for Routiine.io",{"name":9,"bio":10},{"type":12,"value":3353,"toc":3471},[3354,3358,3366,3374,3378,3381,3384,3387,3390,3394,3411,3414,3425,3428,3432,3435,3438,3441,3444,3448,3451,3454,3461,3464],[15,3355,3357],{"id":3356},"the-billing-requirements","The Billing Requirements",[20,3359,3360,3365],{},[27,3361,3364],{"href":3362,"rel":3363},"https://routiine.io",[2302],"Routiine.io"," needed a billing system that supported three plan tiers, per-seat pricing within each tier, annual and monthly billing cycles, a free tier with functional limitations, upgrade and downgrade flows, and prorated charges for mid-cycle plan changes. This is a common set of requirements for a SaaS product, and Stripe handles most of it natively — but the gaps between what Stripe provides and what a production billing system needs are where the real work lives.",[20,3367,3368,3369,3373],{},"The previous experience with ",[27,3370,3372],{"href":3371},"/blog/bastionglass-payment-processing","Stripe in BastionGlass"," covered transaction-based payments. Subscription billing is a fundamentally different pattern. Transactions are discrete — each payment is independent. Subscriptions are continuous — they create ongoing relationships with recurring charges, entitlement management, and lifecycle events that span months or years.",[15,3375,3377],{"id":3376},"stripe-products-and-price-architecture","Stripe Products and Price Architecture",[20,3379,3380],{},"We structured the Stripe product catalog around the three tiers: Starter, Professional, and Enterprise. Each tier has two prices — monthly and annual — and each price uses per-seat billing. This means the charge for a Professional monthly subscription with five users is different from one with three users, and both are different from the annual equivalent.",[20,3382,3383],{},"Stripe models this naturally with Products (the tier) and Prices (the specific billing configuration). Per-seat pricing uses Stripe's quantity parameter on the subscription — the quantity represents the number of seats, and Stripe multiplies the per-seat price by the quantity to calculate the charge.",[20,3385,3386],{},"The tricky part is seat management. When a customer adds a user to their Routiine.io account, the application needs to update the Stripe subscription quantity. When a user is removed, the quantity decreases. Each of these changes triggers prorated billing — Stripe calculates the cost of the remaining portion of the billing period with the new quantity and adjusts the next invoice accordingly.",[20,3388,3389],{},"We handle this with a synchronization function that runs whenever users are added or removed from an account. The function reads the current user count from the database, compares it to the Stripe subscription quantity, and updates Stripe if they differ. This approach is resilient to race conditions — even if two admins add users simultaneously, the sync function converges to the correct quantity because it reads the ground truth from the database rather than trying to increment or decrement.",[15,3391,3393],{"id":3392},"webhook-processing","Webhook Processing",[20,3395,3396,3397,1880,3400,1880,3403,3406,3407,3410],{},"Stripe webhooks are the backbone of subscription billing. Events like ",[304,3398,3399],{},"invoice.paid",[304,3401,3402],{},"invoice.payment_failed",[304,3404,3405],{},"customer.subscription.updated",", and ",[304,3408,3409],{},"customer.subscription.deleted"," drive the application's understanding of each customer's billing state.",[20,3412,3413],{},"Webhook processing in Routiine.io follows a pattern I have standardized across projects. The webhook endpoint validates the Stripe signature, parses the event, and dispatches it to a type-specific handler. Each handler is idempotent — processing the same event twice produces the same result. This is essential because Stripe may deliver webhooks more than once, and the application needs to handle duplicates gracefully.",[20,3415,1766,3416,3418,3419,3421,3422,3424],{},[304,3417,3399],{}," handler updates the account's billing status and extends the service period. The ",[304,3420,3402],{}," handler transitions the account to a grace period — features continue to work for a configurable number of days while payment is retried. The ",[304,3423,3409],{}," handler downgrades the account to the free tier, preserving data but restricting functionality.",[20,3426,3427],{},"Each handler runs within a database transaction to ensure that the billing state and the entitlement state are always consistent. If the entitlement update fails after the billing state is recorded, the transaction rolls back and the webhook is retried. This prevents the situation where a customer is charged but their features are not activated, or vice versa.",[15,3429,3431],{"id":3430},"entitlement-enforcement","Entitlement Enforcement",[20,3433,3434],{},"Billing and entitlements are separate concerns that need to stay synchronized. The billing system manages charges and subscription lifecycle. The entitlement system manages what features and resources each account can access. They communicate through the webhook handlers, but the entitlement checks happen at the application layer.",[20,3436,3437],{},"Every API endpoint in Routiine.io passes through an entitlement middleware that checks whether the requesting account has access to the requested feature. The middleware reads from a local entitlements table — not from Stripe — which means entitlement checks add zero external latency to API requests.",[20,3439,3440],{},"The entitlements table stores the account's current tier, seat count, and feature flags. Feature flags map specific capabilities to tiers: Salesforce integration requires Enterprise, advanced analytics requires Professional or higher, basic pipeline management is available on all tiers including Free.",[20,3442,3443],{},"This separation means we can adjust the feature mapping without changing the billing configuration. If we decide to move advanced analytics from Professional to Starter, it is a configuration change in the entitlements mapping, not a Stripe product restructure. The billing and the features are decoupled, which gives us flexibility to adjust positioning without engineering work.",[15,3445,3447],{"id":3446},"the-edge-cases","The Edge Cases",[20,3449,3450],{},"The documented Stripe integration flows work well for the standard cases — new subscription, upgrade, downgrade, cancellation. The edge cases are where the engineering effort concentrates.",[20,3452,3453],{},"What happens when a customer's card expires and they do not update it? Stripe retries the charge according to a configurable schedule. During the retry period, the account should continue to function — cutting off access immediately for a failed auto-renewal is a terrible customer experience. We implemented a seven-day grace period where the account retains full functionality while displaying a billing alert. After seven days without successful payment, the account downgrades to Free tier.",[20,3455,3456,3457,3460],{},"What happens when a customer disputes a charge? Stripe notifies us via the ",[304,3458,3459],{},"charge.dispute.created"," webhook. We do not automatically restrict the account during a dispute because the customer may be legitimate and the dispute may be resolved in our favor. But we do flag the account for review and pause any upcoming charges until the dispute is resolved.",[20,3462,3463],{},"What happens during a Stripe outage? Entitlement checks use the local database, so the application continues to function. New subscriptions cannot be created during the outage, but existing subscriptions are unaffected because their entitlements are already stored locally. When Stripe recovers, the webhook backlog processes and any missed state changes are applied.",[20,3465,3466,3467,1036],{},"These edge cases are not exotic scenarios — they happen to every SaaS product at scale. Handling them well is the difference between a billing system that works and one that ",[27,3468,3470],{"href":3469},"/blog/stripe-subscription-billing","works in production",{"title":105,"searchDepth":106,"depth":106,"links":3472},[3473,3474,3475,3476,3477],{"id":3356,"depth":109,"text":3357},{"id":3376,"depth":109,"text":3377},{"id":3392,"depth":109,"text":3393},{"id":3430,"depth":109,"text":3431},{"id":3446,"depth":109,"text":3447},"How I built the subscription billing system for Routiine.io — Stripe integration, plan tiers, usage metering, and handling the edge cases that documentation does not cover.",[3480,3481,3482],"stripe subscription billing saas","multi-tier billing implementation","saas billing system design",{},"/blog/routiine-io-stripe-billing",{"title":3350,"description":3478},"blog/routiine-io-stripe-billing",[3488,3489,2105,3490,3491],"Stripe","Billing","TypeScript","Subscriptions","eNfKOBbvrZ9hVGV2xT_Xm8ZWN_3-_iFC5RvF3SRp7es",{"id":3494,"title":3495,"author":3496,"body":3497,"category":3632,"date":3329,"description":3633,"extension":116,"featured":117,"image":118,"keywords":3634,"meta":3637,"navigation":127,"path":3638,"readTime":369,"seo":3639,"stem":3640,"tags":3641,"__hash__":3645},"blog/blog/technology-due-diligence.md","Technology Due Diligence: What Investors Look For",{"name":9,"bio":10},{"type":12,"value":3498,"toc":3627},[3499,3503,3506,3509,3513,3516,3522,3525,3531,3539,3550,3556,3560,3563,3569,3575,3581,3587,3591,3594,3600,3606,3612,3618,3624],[3500,3501,3495],"h1",{"id":3502},"technology-due-diligence-what-investors-look-for",[20,3504,3505],{},"Technology due diligence is the process investors use to evaluate the technical foundation of a company before investing. It answers a simple question: can this team build what they are promising with the technology they have?",[20,3507,3508],{},"I have participated in due diligence processes on both sides — as the technical founder being evaluated and as the technical advisor conducting the evaluation. The companies that pass due diligence smoothly are not necessarily the ones with the most sophisticated technology. They are the ones that can clearly articulate their technical decisions, demonstrate that their architecture supports their growth plan, and show that their engineering practices are sustainable.",[15,3510,3512],{"id":3511},"what-gets-evaluated","What Gets Evaluated",[20,3514,3515],{},"Due diligence covers several dimensions of your technology operation. Each one tells investors something specific about risk.",[20,3517,3518,3521],{},[178,3519,3520],{},"Architecture and scalability."," Can your current architecture support 10x your current user base without a rewrite? Investors are not expecting you to have already built for massive scale, but they want to see that you have thought about it. A monolithic application with clear boundaries that can be decomposed into services is fine. A monolithic application with tangled dependencies that will require a six-month rewrite to handle growth is a red flag.",[20,3523,3524],{},"Document your architecture — a clear diagram showing services, databases, external dependencies, and data flow. Explain the trade-offs you made and why they were appropriate for your stage. If you chose a simple architecture because you are pre-product-market-fit and speed matters more than scalability, say that. It demonstrates maturity.",[20,3526,3527,3530],{},[178,3528,3529],{},"Code quality and engineering practices."," Investors will often have a technical advisor review your codebase. They are looking for consistent coding standards, reasonable test coverage, meaningful code review practices, and absence of obvious security vulnerabilities. They are not looking for perfection — they are looking for evidence that your team writes maintainable code.",[20,3532,3533,3534,3538],{},"The biggest red flag in code review is not a specific bug. It is the absence of automated quality controls. No tests, no linting, no CI/CD pipeline, no code review process — this tells investors that the team ships whatever they write without verification. The ",[27,3535,3537],{"href":3536},"/blog/technical-debt-business-impact","technical debt"," accumulated in this environment compounds rapidly.",[20,3540,3541,3544,3545,3549],{},[178,3542,3543],{},"Security posture."," How do you handle authentication, authorization, and data protection? Are secrets managed properly or hardcoded in the repository? Do you have a process for addressing security vulnerabilities in dependencies? Investors increasingly care about security because breaches destroy both user trust and company value. A basic security review that covers the ",[27,3546,3548],{"href":3547},"/blog/owasp-top-10-explained","OWASP Top 10"," demonstrates awareness.",[20,3551,3552,3555],{},[178,3553,3554],{},"Team and knowledge distribution."," Is critical knowledge concentrated in one person? If your entire backend architecture is understood only by a single engineer, that is a key-person risk. Investors want to see knowledge distributed across the team through documentation, code review practices, and shared ownership of critical systems.",[15,3557,3559],{"id":3558},"preparing-for-due-diligence","Preparing for Due Diligence",[20,3561,3562],{},"Preparation should start long before you are in fundraising conversations. The best time to build good practices is when they are easy to implement, not when an investor is asking for evidence of them.",[20,3564,3565,3568],{},[178,3566,3567],{},"Document your architecture decisions."," Maintain Architecture Decision Records that explain why you chose your database, framework, hosting provider, and other significant technical choices. These do not need to be formal — a paragraph explaining the decision and its rationale is sufficient. The existence of these documents tells investors that your technical decisions are deliberate, not accidental.",[20,3570,3571,3574],{},[178,3572,3573],{},"Maintain a dependency inventory."," Know what third-party services and libraries you depend on, what they cost, and what your fallback plan is if one becomes unavailable. Investors will ask about vendor concentration risk — if your entire application depends on a single third-party API with no alternative, that is a risk they need to understand.",[20,3576,3577,3580],{},[178,3578,3579],{},"Track your technical debt."," Every codebase has technical debt. Investors do not expect otherwise. What they want to see is that you know where the debt is, you have a plan for addressing it, and it is not accumulating faster than you can pay it down. A prioritized list of known issues with estimated remediation effort demonstrates control.",[20,3582,3583,3586],{},[178,3584,3585],{},"Have your infrastructure documented."," How is the application deployed? What happens if a server fails? How are backups managed? How do you handle incidents? These operational questions are part of due diligence because they tell investors whether your technology operation can sustain the growth that their investment is intended to fund.",[15,3588,3590],{"id":3589},"red-flags-that-kill-deals","Red Flags That Kill Deals",[20,3592,3593],{},"Certain findings during due diligence will significantly reduce investor confidence or kill the deal entirely.",[20,3595,3596,3599],{},[178,3597,3598],{},"No version control or no meaningful commit history."," If your code is not in Git with a meaningful history of changes, investors question whether the team has basic engineering discipline. Similarly, a commit history that shows one person making all commits for the past twelve months raises team questions.",[20,3601,3602,3605],{},[178,3603,3604],{},"Plaintext secrets in the repository."," Database passwords, API keys, and encryption keys committed to the code repository signal a fundamental security gap that puts customer data at risk. This is one of the easiest problems to prevent and one of the most damaging when found during due diligence.",[20,3607,3608,3611],{},[178,3609,3610],{},"No automated testing."," Some investors tolerate low test coverage in early-stage companies, but no testing infrastructure at all is a different signal. It suggests that the team ships code without any verification beyond manual testing, which becomes unsustainable as the codebase grows.",[20,3613,3614,3617],{},[178,3615,3616],{},"Undisclosed third-party IP issues."," Using open-source libraries with copyleft licenses in proprietary software, or incorporating code from previous employers, creates legal liability that investors must understand. Disclose these proactively. Discovering them during due diligence feels like concealment, even if it was an oversight.",[20,3619,3620,3623],{},[178,3621,3622],{},"Architecture that cannot support the business plan."," If your pitch deck projects ten million users and your architecture uses SQLite on a single server, the gap between ambition and technical reality undermines your credibility. Your architecture does not need to be built for ten million users today, but the path from current state to projected scale should be plausible.",[20,3625,3626],{},"Due diligence is not an exam you pass or fail. It is a conversation about risk, capability, and trajectory. Investors expect imperfections in early-stage technology — they are investing in potential. What they cannot accept is unacknowledged risk, undocumented decisions, and engineering practices that will not scale with the investment they are making.",{"title":105,"searchDepth":106,"depth":106,"links":3628},[3629,3630,3631],{"id":3511,"depth":109,"text":3512},{"id":3558,"depth":109,"text":3559},{"id":3589,"depth":109,"text":3590},"Business","Before writing a check, investors evaluate your technology. Here's what they look for — and what technical founders should prepare before fundraising.",[3635,3636],"technology due diligence","technical due diligence investors",{},"/blog/technology-due-diligence",{"title":3495,"description":3633},"blog/technology-due-diligence",[3642,3643,3644],"Due Diligence","Startups","Investment","UwVJj1B64jDM_PiTD6HdJ3Co_kGl4AxEjokAcfpOsXM",{"id":3647,"title":3648,"author":3649,"body":3650,"category":113,"date":3821,"description":3822,"extension":116,"featured":117,"image":118,"keywords":3823,"meta":3829,"navigation":127,"path":3830,"readTime":369,"seo":3831,"stem":3832,"tags":3833,"__hash__":3839},"blog/blog/parish-registers-family-history.md","Parish Registers: The Backbone of Family History Research",{"name":9,"bio":10},{"type":12,"value":3651,"toc":3813},[3652,3656,3659,3662,3665,3669,3672,3678,3684,3690,3693,3697,3700,3706,3712,3722,3728,3732,3735,3741,3747,3758,3764,3770,3774,3777,3785,3788,3791,3793,3795],[15,3653,3655],{"id":3654},"the-foundation-of-modern-genealogy","The Foundation of Modern Genealogy",[20,3657,3658],{},"Before civil registration -- before the state began recording births, marriages, and deaths -- the church recorded them. Parish registers, maintained by clergy in every parish across Britain, Ireland, and much of Europe, are the primary documentary source for family history from the sixteenth century to the nineteenth century.",[20,3660,3661],{},"In England and Wales, parish registration began in 1538, when Thomas Cromwell ordered every parish to keep a register of baptisms, marriages, and burials. In Scotland, registration began in 1553, though compliance was uneven and many registers do not begin until the seventeenth or eighteenth century. In Ireland, Church of Ireland registers begin sporadically in the seventeenth century, while Roman Catholic registers generally do not begin until the late eighteenth or early nineteenth century -- a gap that reflects the legal disabilities imposed on Catholics under the Penal Laws.",[20,3663,3664],{},"Civil registration -- the state system that runs parallel to and eventually supersedes parish registration -- began in England and Wales in 1837, in Scotland in 1855, and in Ireland in 1864. Before those dates, parish registers are often the only source for vital events.",[15,3666,3668],{"id":3667},"what-parish-registers-contain","What Parish Registers Contain",[20,3670,3671],{},"The content of parish registers varies by period, denomination, and the conscientiousness of the individual clergyman. The basic entries record:",[20,3673,3674,3677],{},[178,3675,3676],{},"Baptisms"," (not births): The date of baptism, the child's name, the father's name, and sometimes the mother's name, the father's occupation, and the family's place of residence. Before the mid-eighteenth century, many registers record only the child's name and the father's name. After Hardwicke's Marriage Act (1754) and Rose's Act (1812), entries became more standardized and informative.",[20,3679,3680,3683],{},[178,3681,3682],{},"Marriages",": The date of marriage, the names of the bride and groom, and after 1754, the signatures or marks of both parties and their witnesses, the parish of residence, and whether the marriage was by banns or by license. Pre-1754 entries can be sparse -- sometimes only the names and date.",[20,3685,3686,3689],{},[178,3687,3688],{},"Burials"," (not deaths): The date of burial and the name of the deceased. Cause of death is rarely recorded. Age at death is sometimes given in later registers but is often unreliable.",[20,3691,3692],{},"The distinction between baptism and birth, and between burial and death, matters. A child baptized on March 15 may have been born days, weeks, or even months earlier. A person buried on June 20 may have died the day before or the week before, especially in rural areas where the distance to the parish church was significant.",[15,3694,3696],{"id":3695},"where-to-find-them","Where to Find Them",[20,3698,3699],{},"The original registers are held in a variety of locations:",[20,3701,3702,3705],{},[178,3703,3704],{},"County record offices"," (in England and Wales) hold the deposited registers of most Church of England parishes. Many have been digitized and are available through commercial genealogy websites (Ancestry, Findmypast) or through the free FamilySearch website.",[20,3707,3708,3711],{},[178,3709,3710],{},"The National Records of Scotland"," holds the Old Parochial Registers (OPRs) of the Church of Scotland, which have been digitized and are searchable through ScotlandsPeople (scotlandspeople.gov.uk), the official government genealogy service.",[20,3713,3714,3717,3718,3721],{},[178,3715,3716],{},"The Public Record Office of Northern Ireland"," (PRONI) and the ",[178,3719,3720],{},"National Archives of Ireland"," hold surviving Irish registers, though the catastrophic destruction of the Public Record Office in Dublin in 1922 (during the Civil War) destroyed many Church of Ireland registers. Roman Catholic registers for Ireland largely survive because they were held locally by parishes and were not in the Four Courts when it burned.",[20,3723,3724,3727],{},[178,3725,3726],{},"Bishop's Transcripts"," -- annual copies of parish register entries sent to the diocesan bishop -- provide a backup when the original registers are lost or damaged. They survive in quantity for many English dioceses and are held at county record offices.",[15,3729,3731],{"id":3730},"how-to-use-them-effectively","How to Use Them Effectively",[20,3733,3734],{},"Working with parish registers requires patience, flexibility, and a tolerance for ambiguity. Several principles will save time and reduce errors.",[20,3736,3737,3740],{},[178,3738,3739],{},"Search broadly."," People did not always use the parish nearest their home. They might be baptized in one parish, married in another (the bride's parish was customary), and buried in a third. Search neighboring parishes as well as the expected one.",[20,3742,3743,3746],{},[178,3744,3745],{},"Expect spelling variation."," Before universal literacy, names were recorded as the clergyman heard them. The same family might appear as Smith, Smyth, and Smythe in consecutive entries. Surnames were not standardized until well into the nineteenth century.",[20,3748,3749,3752,3753,3757],{},[178,3750,3751],{},"Cross-reference."," A single parish register entry proves very little on its own. A baptism proves that a child with that name was born to parents with those names in that place at that time. It does not prove that this is your ancestor rather than a cousin or an unrelated family with the same name. Build chains of evidence: baptism linked to marriage linked to burial, with supporting evidence from ",[27,3754,3756],{"href":3755},"/blog/census-records-genealogy","census records",", wills, and other sources.",[20,3759,3760,3763],{},[178,3761,3762],{},"Watch for gaps."," Many parish registers have gaps -- periods when entries were not recorded, or when the register was lost. The English Civil War period (1640s-1650s) is notorious for poor registration. The Commonwealth government attempted to transfer registration to civil officials, and the transition was chaotic. If you cannot find an ancestor in the expected register during the 1640s or 1650s, the gap may be in the records, not in the family.",[20,3765,3766,3769],{},[178,3767,3768],{},"Read the originals."," Transcriptions and indexes are invaluable for finding entries, but they contain errors. If a transcription does not make sense, go back to the original image. Handwriting that puzzled a transcriber may be clear in context, and paleographic features -- abbreviations, letter forms, marginalia -- are lost in transcription.",[15,3771,3773],{"id":3772},"the-registers-and-the-lives-behind-them","The Registers and the Lives Behind Them",[20,3775,3776],{},"Parish registers are laconic documents. A baptism entry might be three words: a date, a name, a father's name. A burial entry might be two words: a date and a name. There is no room for personality, for circumstance, for the texture of a life.",[20,3778,3779,3780,3784],{},"And yet these entries are the fixed points around which a family history can be built. Each baptism is a new generation. Each marriage is the formation of a new household. Each burial is the end of a story that the ",[27,3781,3783],{"href":3782},"/blog/family-history-documentary-research","documentary record"," may or may not preserve.",[20,3786,3787],{},"The parish register does not tell you who your ancestors were. It tells you that they were. The rest -- the context, the community, the circumstances -- must be assembled from other sources. But the register is the starting point, the skeleton on which the flesh of the story is built.",[20,3789,3790],{},"For anyone beginning family history research, the parish registers are where you learn to work. For anyone pushing research back into the sixteenth and seventeenth centuries, they are where the trail often goes cold -- and where the satisfaction of finding the next link is greatest.",[954,3792],{},[15,3794,1419],{"id":1418},[1113,3796,3797,3802,3808],{},[713,3798,3799],{},[27,3800,3801],{"href":3755},"Census Records: Snapshots of Your Ancestors' Lives",[713,3803,3804],{},[27,3805,3807],{"href":3806},"/blog/genealogy-medieval-records","Medieval Records and Genealogy: What Survives and Where to Find It",[713,3809,3810],{},[27,3811,3812],{"href":3782},"Documentary Research: Building a Family History from Primary Sources",{"title":105,"searchDepth":106,"depth":106,"links":3814},[3815,3816,3817,3818,3819,3820],{"id":3654,"depth":109,"text":3655},{"id":3667,"depth":109,"text":3668},{"id":3695,"depth":109,"text":3696},{"id":3730,"depth":109,"text":3731},{"id":3772,"depth":109,"text":3773},{"id":1418,"depth":109,"text":1419},"2025-12-21","Parish registers recording baptisms, marriages, and burials are the single most important source for tracing family history before civil registration. Here is what they contain, where they survive, and how to use them.",[3824,3825,3826,3827,3828],"parish registers genealogy","parish records family history","baptism marriage burial records","church records genealogy","how to use parish registers",{},"/blog/parish-registers-family-history",{"title":3648,"description":3822},"blog/parish-registers-family-history",[3834,3835,3836,3837,3838],"Parish Registers","Family History","Genealogy Research","Church Records","Historical Records","4yDujPA1ufnUc7b3yR2mmsJFE1CdYFPmPIKfTan434o",{"id":3841,"title":3842,"author":3843,"body":3845,"category":113,"date":3949,"description":3950,"extension":116,"featured":117,"image":118,"keywords":3951,"meta":3957,"navigation":127,"path":3958,"readTime":369,"seo":3959,"stem":3960,"tags":3961,"__hash__":3967},"blog/blog/celtic-music-origins.md","Celtic Music: Ancient Roots of a Living Tradition",{"name":9,"bio":3844},"Author of The Forge of Tongues — 22,000 Years of Migration, Mutation, and Memory",{"type":12,"value":3846,"toc":3943},[3847,3851,3859,3862,3869,3873,3880,3883,3890,3897,3901,3909,3920,3928,3932,3935],[15,3848,3850],{"id":3849},"sound-before-writing","Sound Before Writing",[20,3852,3853,3854,3858],{},"Before the Celts committed anything to paper, they committed it to sound. The ",[27,3855,3857],{"href":3856},"/blog/druids-oak-knowledge-tradition","druidic tradition"," that governed Celtic intellectual life was explicitly oral — knowledge was transmitted through verse, recitation, and song. Music was not a separate category of cultural production. It was woven into every aspect of Celtic life: religion, warfare, storytelling, mourning, celebration, and the daily rhythms of agricultural work.",[20,3860,3861],{},"The earliest evidence of Celtic music is archaeological. The carnyx — a tall bronze war trumpet shaped like a boar or serpent, held vertically so its bell projected above the heads of warriors — has been found at sites across the Celtic world, from Scotland to Romania. The Deskford Carnyx, discovered in Aberdeenshire, is one of the finest surviving examples: a boar-headed trumpet that, when reconstructed and played, produces a deep, resonant bellow that would have carried across a battlefield. Classical writers describe the terrifying noise of Celtic armies, where the sound of carnyxes, war cries, and clashing weapons was itself a weapon of psychological warfare.",[20,3863,3864,3865,3868],{},"Beyond the battlefield, the literary sources describe a rich musical culture. The Irish and Welsh texts mention harps, pipes, horns, and drums. The harpist held a particularly honored position in Gaelic society — the ",[234,3866,3867],{},"cruitire"," was a professional musician whose social status was defined by law. The Brehon Laws specify the rights and obligations of musicians with the same precision they apply to other professional classes, indicating that music was not a casual pastime but a regulated profession carrying social prestige.",[15,3870,3872],{"id":3871},"the-harp-the-pipes-and-the-voice","The Harp, the Pipes, and the Voice",[20,3874,3875,3876,3879],{},"The harp is the instrument most closely associated with the Celtic tradition. Triangular frame harps appear in Irish and Scottish art from the early medieval period, and harp playing is documented continuously from at least the tenth century. The harp was the instrument of the professional musician — the bard or ",[234,3877,3878],{},"ollamh"," — who performed at the chief's table, composing praise poetry for his patron and satire for his patron's enemies.",[20,3881,3882],{},"The clarsach, the small Celtic harp strung with wire rather than gut, produced a bright, resonant tone that defined Gaelic court music. Turlough O'Carolan (1670-1738), the last great Irish harper, composed music bridging the Gaelic tradition and baroque Europe. His death marked the end of the professional harpist tradition.",[20,3884,3885,3886,3889],{},"The bagpipe, though often thought of as uniquely Scottish, has roots across the Celtic world. The Great Highland Bagpipe developed its character in Scotland, serving as both a military instrument and a vehicle for ",[234,3887,3888],{},"ceol mor"," — the classical music known as piobaireachd. This complex form of theme and variation was transmitted orally using sung syllables called canntaireachd until codified in the eighteenth century.",[20,3891,3892,3893,3896],{},"The voice remains the most fundamental instrument. Gaelic song — ",[234,3894,3895],{},"sean-nos"," in Ireland — is unaccompanied, highly ornamented, and deeply personal. The singer inhabits the song, decorating the melody with turns and grace notes never exactly the same twice. This improvisatory quality links it to the older oral tradition, where the performer was re-creating a living text.",[15,3898,3900],{"id":3899},"music-and-memory","Music and Memory",[20,3902,3903,3904,3908],{},"Celtic music has always served a function beyond entertainment. It is a technology of memory. The songs preserve history, genealogy, landscape, and emotional truth in forms that can be transmitted across generations without writing. A ",[27,3905,3907],{"href":3906},"/blog/scottish-gaelic-language-history","Gaelic waulking song"," — sung by women rhythmically beating cloth to shrink it — might contain verses that reference events from centuries earlier, embedded in a work song that was renewed with each performance.",[20,3910,3911,3912,3915,3916,3919],{},"The lament tradition — the ",[234,3913,3914],{},"cumha"," in Gaelic — is perhaps the most powerful example. Gaelic laments for the dead are among the most emotionally intense forms of vocal music in any tradition. They preserve not just grief but specific memories: of individuals, of places, of departures forced by the ",[27,3917,3918],{"href":101},"Highland Clearances"," or by economic necessity. The great Gaelic lament \"Cumha Ghriogair\" (Lament for Griogair), attributed to the wife of a MacGregor chief executed in the sixteenth century, is still performed today — four hundred years of continuous mourning carried in a song.",[20,3921,3922,3923,3927],{},"This function of music as memory is why the suppression of Gaelic culture ",[27,3924,3926],{"href":3925},"/blog/culloden-aftermath-highlands","after Culloden"," targeted musical expression as well as language and dress. The British government classified the bagpipe as an instrument of war. The patronage system that supported professional musicians collapsed with the clan system. The music survived because it did not depend on institutions — it lived in families, in communities, in the voices of people who carried it not as performance but as inheritance.",[15,3929,3931],{"id":3930},"a-living-tradition","A Living Tradition",[20,3933,3934],{},"Celtic music is not a museum piece. It is one of the most vital folk music traditions in the world. The Irish session — musicians playing traditional tunes in a pub — is a living descendant of the gatherings where tunes were shared and transmitted across the Gaelic world. Scottish pipe bands, Cape Breton step dancers, Appalachian banjo pickers playing tunes their ancestors brought from Ulster — all branches of the same living tree.",[20,3936,3937,3938,3942],{},"The tradition survives because it adapts. It absorbed influences from baroque Europe, from Scandinavian fiddle traditions through ",[27,3939,3941],{"href":3940},"/blog/norse-gaels-hybrid-culture","Norse-Gaelic"," contact in the Hebrides, from African-American music through diaspora communities in North America. Through all of it, the core persists: the tunes, the ornaments, the rhythmic vitality, and the deep connection to a culture that valued music not as entertainment but as a way of being in the world.",{"title":105,"searchDepth":106,"depth":106,"links":3944},[3945,3946,3947,3948],{"id":3849,"depth":109,"text":3850},{"id":3871,"depth":109,"text":3872},{"id":3899,"depth":109,"text":3900},{"id":3930,"depth":109,"text":3931},"2025-12-20","Celtic music is one of the oldest continuously practiced musical traditions in Europe. From the war trumpets of the Iron Age to the fiddle tunes of a modern pub session, the tradition has adapted, evolved, and survived because it was always more than entertainment — it was the sound of a culture remembering itself.",[3952,3953,3954,3955,3956],"celtic music origins","celtic music history","irish traditional music","scottish music tradition","gaelic music",{},"/blog/celtic-music-origins",{"title":3842,"description":3950},"blog/celtic-music-origins",[3962,3963,3964,3965,3966],"Celtic Music","Scottish Music","Irish Music","Musical Tradition","Gaelic Culture","UuuyyDF6zfgr7tc2mh11X827xJL_NGToXwIu-3462Gk",{"id":3969,"title":3970,"author":3971,"body":3972,"category":113,"date":3949,"description":4141,"extension":116,"featured":117,"image":118,"keywords":4142,"meta":4149,"navigation":127,"path":4150,"readTime":369,"seo":4151,"stem":4152,"tags":4153,"__hash__":4157},"blog/blog/dna-surname-projects.md","DNA Surname Projects: Connecting Families Through Genetics",{"name":9,"bio":10},{"type":12,"value":3973,"toc":4134},[3974,3978,3981,3984,3987,3993,3997,4005,4014,4026,4029,4049,4053,4056,4062,4068,4074,4078,4081,4091,4097,4103,4109,4112,4114,4116],[15,3975,3977],{"id":3976},"the-surname-problem-in-genealogy","The Surname Problem in Genealogy",[20,3979,3980],{},"Surnames seem simple. You share a last name with your father, who shared it with his father, in an unbroken chain stretching back to whenever the surname was first adopted. If two people share a surname, they must share a common ancestor — right?",[20,3982,3983],{},"Not necessarily. Most European surnames were adopted between the eleventh and sixteenth centuries, and the same name was often adopted independently by unrelated families. A man named Ross in Easter Ross, Scotland, might have taken the name from the Gaelic word for \"headland\" or from the territory of Ross. Another man named Ross in Renfrewshire might have adopted it for entirely different reasons. A third man named Ross in England might descend from Norman settlers who took the name from a place in Normandy. Same surname, three separate origins, no shared ancestor.",[20,3985,3986],{},"Traditional genealogy can sometimes untangle these threads through parish records, estate documents, and wills. But paper records have limits — most family histories hit a wall between the 1600s and 1800s where documentation runs out. Beyond that wall, the question \"are these two Ross families actually related?\" becomes unanswerable through documentary evidence alone.",[20,3988,3989,3992],{},[178,3990,3991],{},"DNA surname projects"," were created to answer exactly this question.",[15,3994,3996],{"id":3995},"how-surname-projects-work","How Surname Projects Work",[20,3998,3999,4000,4004],{},"A DNA surname project aggregates ",[27,4001,4003],{"href":4002},"/blog/y-dna-haplogroups-explained","Y-chromosome DNA"," results from men who share a surname (or a variant of it). Because the Y-chromosome passes from father to son in the same pattern as most European surnames, men who share both a surname and a common patrilineal ancestor should carry similar or identical Y-DNA signatures.",[20,4006,4007,4008,4013],{},"The projects are hosted primarily on ",[27,4009,4012],{"href":4010,"rel":4011},"https://www.familytreedna.com",[2302],"FamilyTreeDNA",", which provides a platform for project administrators to organize results, group participants into genetic clusters, and publish findings. Any man who has taken a Y-DNA test at FamilyTreeDNA can join the surname project for his family name (joining is free once you have test results).",[20,4015,4016,4017,4020,4021,4025],{},"When enough participants have tested, patterns emerge. The project administrator can identify ",[178,4018,4019],{},"genetic clusters"," — groups of men who match each other closely on STR markers and share the same ",[27,4022,4024],{"href":4023},"/blog/snp-mutations-explained","SNP-defined haplogroup",". Each cluster represents a distinct patrilineal lineage within the surname.",[20,4027,4028],{},"A well-developed surname project typically reveals:",[1113,4030,4031,4037,4043],{},[713,4032,4033,4036],{},[178,4034,4035],{},"One or more major clusters"," representing the core genetic lineage(s) of the surname — the families that are actually related through a common male-line ancestor",[713,4038,4039,4042],{},[178,4040,4041],{},"Singleton results"," that do not match any cluster — men who carry the surname but whose Y-DNA shows a different genetic origin, indicating independent adoption of the name",[713,4044,4045,4048],{},[178,4046,4047],{},"Unexpected haplogroup results"," that reveal non-paternity events, adoptions, or name changes somewhere in the patrilineal chain",[15,4050,4052],{"id":4051},"what-surname-projects-reveal","What Surname Projects Reveal",[20,4054,4055],{},"The findings of mature surname projects consistently demonstrate that surnames are far less reliable as indicators of shared ancestry than most people assume.",[20,4057,4058,4061],{},[178,4059,4060],{},"Multiple origins are common."," Most surname projects with a significant number of participants discover that the surname has two, three, or more independent genetic origins. The men carrying these different lineages share a name but not a male-line ancestor. Their common surname is a coincidence of naming practices, not evidence of kinship.",[20,4063,4064,4067],{},[178,4065,4066],{},"Non-paternity events are visible."," Occasionally, a participant who can document their Ross (or Smith, or O'Brien) ancestry back several centuries through paper records will show a Y-DNA result that does not match the main genetic cluster. This indicates a \"non-paternity event\" — at some point in the patrilineal chain, the biological father was not the man of record. The surname continued, but the Y-chromosome did not. Surname projects estimate that non-paternity rates across documented genealogical lines are roughly 1-2% per generation — low enough to be rare, but high enough that over ten or fifteen generations, a significant minority of lines will show a disconnect.",[20,4069,4070,4073],{},[178,4071,4072],{},"Geographic sub-clusters emerge."," Within a single genetic cluster, closer matching groups of men can sometimes be associated with specific geographic regions. In a Ross surname project, for example, men whose documented ancestry traces to Easter Ross, Scotland, might cluster together with very close STR matches, while men from a different Scottish region form a separate sub-cluster within the same broader haplogroup. These sub-clusters represent more recent branching within the last several hundred years.",[15,4075,4077],{"id":4076},"getting-the-most-from-a-surname-project","Getting the Most from a Surname Project",[20,4079,4080],{},"If you are considering joining a DNA surname project, a few practical points are worth noting.",[20,4082,4083,4086,4087,1036],{},[178,4084,4085],{},"Test at a useful resolution."," A basic Y-37 test provides enough STR markers to determine whether you match other participants at a general level. For precise placement within a surname project's clusters, Y-111 is significantly more informative. For the deepest resolution — assignment to a specific branch within the haplogroup tree — the Big Y-700 test at FamilyTreeDNA is the standard. The additional cost of higher-resolution testing is generally worth it for serious ",[27,4088,4090],{"href":4089},"/blog/what-is-genetic-genealogy","genealogical research",[20,4092,4093,4096],{},[178,4094,4095],{},"Document your paper trail."," Your DNA result becomes far more valuable when paired with whatever documentary genealogy you have. Even a partial family tree — \"my earliest known ancestor is John Ross, born approximately 1780 in Easter Ross, Scotland\" — helps the project administrator place your result in context and identify which geographic sub-cluster you might belong to.",[20,4098,4099,4102],{},[178,4100,4101],{},"Be prepared for surprises."," Surname projects regularly deliver results that contradict family traditions. You might discover that your line is not genetically related to the main body of the surname. You might discover a connection to a family you had no knowledge of. The value of the project is in the data, not in confirmation of expectations.",[20,4104,4105,4108],{},[178,4106,4107],{},"Participate in the community."," The best surname projects are collaborative enterprises. Project administrators volunteer their time to organize results and correspond with participants. Contributing your results, your documentary research, and your engagement makes the project more useful for everyone — including future participants who may be your genetic relatives.",[20,4110,4111],{},"The power of a surname project lies in aggregation. A single Y-DNA test tells you your haplogroup. A hundred Y-DNA tests from men sharing your surname tell you how many separate families carry that name, which families are genetically connected, and where the branching points are. That collective picture is something no individual test — and no paper archive — can provide alone.",[954,4113],{},[15,4115,1419],{"id":1418},[1113,4117,4118,4123,4128],{},[713,4119,4120],{},[27,4121,4122],{"href":4089},"What Is Genetic Genealogy? A Beginner's Guide",[713,4124,4125],{},[27,4126,4127],{"href":4023},"SNP Mutations: The Genetic Markers That Track Ancestry",[713,4129,4130],{},[27,4131,4133],{"href":4132},"/blog/triangulation-dna-matches","Triangulation: Confirming DNA Matches with Shared Segments",{"title":105,"searchDepth":106,"depth":106,"links":4135},[4136,4137,4138,4139,4140],{"id":3976,"depth":109,"text":3977},{"id":3995,"depth":109,"text":3996},{"id":4051,"depth":109,"text":4052},{"id":4076,"depth":109,"text":4077},{"id":1418,"depth":109,"text":1419},"DNA surname projects aggregate Y-chromosome results from men who share a surname, revealing which families are genetically related and which adopted the same name independently. Here's how they work and why they matter for genealogy.",[4143,4144,4145,4146,4147,4148],"dna surname project","y dna surname project","familytreedna surname project","genetic genealogy surname","connecting families dna","surname dna testing",{},"/blog/dna-surname-projects",{"title":3970,"description":4141},"blog/dna-surname-projects",[4154,4155,4156,3835,4012],"DNA Surname Projects","Genetic Genealogy","Y-DNA","kwpYre-ymYvPfZC8ZyFvltQqAVMuXLzKC7FJvXO3HBk",[4159,4160,4161,4163,4164,4165,4166,4167,4168,4169,4170,4171,4172,4173,4174,4175,4176,4177,4178,4179,4180,4181,4182,4183,4184,4185,4186,4187,4188,4189,4190,4191,4192,4193,4194,4195,4196,4197,4198,4200,4201,4202,4203,4204,4205,4206,4207,4208,4209,4210,4211,4212,4213,4214,4215,4216,4217,4218,4219,4220,4221,4222,4223,4224,4225,4226,4227,4228,4229,4230,4231,4232,4233,4234,4235,4236,4237,4238,4239,4240,4241,4242,4244,4245,4246,4247,4248,4249,4250,4251,4252,4253,4254,4255,4256,4257,4258,4259,4260,4261,4262,4263,4264,4265,4266,4267,4268,4269,4270,4271,4272,4273,4274,4275,4276,4277,4278,4279,4280,4281,4282,4283,4284,4285,4286,4287,4288,4289,4290,4291,4292,4293,4294,4295,4296,4297,4298,4299,4300,4301,4302,4303,4304,4305,4306,4307,4308,4309,4310,4311,4312,4313,4314,4315,4316,4317,4318,4319,4320,4321,4322,4323,4324,4325,4326,4327,4328,4329,4330,4331,4332,4333,4334,4335,4336,4337,4338,4339,4340,4341,4342,4343,4344,4345,4346,4347,4348,4349,4350,4351,4352,4353,4354,4355,4356,4357,4358,4359,4360,4361,4362,4363,4364,4365,4366,4367,4368,4369,4370,4371,4372,4373,4374,4375,4376,4377,4378,4379,4380,4381,4382,4383,4384,4385,4386,4387,4388,4389,4390,4391,4392,4393,4394,4395,4396,4397,4398,4399,4400,4401,4402,4403,4404,4405,4406,4407,4408,4409,4410,4411,4412,4413,4414,4415,4416,4417,4418,4419,4420,4421,4422,4423,4424,4425,4426,4427,4428,4429,4430,4431,4432,4433,4434,4435,4436,4437,4438,4439,4440,4441,4442,4443,4444,4445,4446,4447,4448,4449,4450,4451,4452,4453,4454,4455,4456,4457,4458,4459,4460,4461,4462,4463,4464,4465,4466,4467,4468,4469,4470,4471,4472,4473,4474,4475,4476,4477,4478,4479,4480,4481,4482,4483,4484,4485,4486,4487,4488,4489,4490,4491,4492,4493,4494,4495,4496,4497,4498,4499,4500,4501,4502,4503,4504,4505,4506,4507,4508,4509,4510,4511,4512,4513,4514,4515,4516,4517,4518,4519,4520,4521,4522,4523,4524,4525,4526,4527,4528,4529,4530,4531,4532,4533,4534,4535,4536,4537,4538,4539,4540,4541,4542,4543,4544,4545,4546,4547,4548,4549,4550,4551,4552,4553,4554,4555,4556,4557,4558,4559,4560,4561,4562,4563,4564,4565,4566,4567,4568,4569,4570,4571,4572,4573,4574,4575,4576,4577,4578,4579,4580,4581,4582,4583,4584,4585,4586,4587,4588,4589,4590,4591,4592,4593,4594,4595,4596,4597,4598,4599,4600,4601,4602,4603,4604,4605,4606,4607,4608,4609,4610,4611,4612,4613,4614,4615,4616,4617,4618,4619,4620,4621,4622,4623,4624,4625,4626,4627,4628,4629,4630,4631,4632,4634,4635,4636,4637,4638,4639,4640,4641,4642,4643,4644,4645,4646,4647,4648,4649,4650,4651,4652,4653,4654,4655,4656,4657,4658,4659,4660,4661,4662,4663,4664,4665,4666,4667,4668,4669,4670,4671,4672,4673,4674,4675,4676,4677,4678,4679,4680,4681,4682,4683,4684,4685,4686,4687,4688,4689,4690,4691,4692,4693,4694,4695,4696,4697,4698,4699,4700,4701,4702,4703,4704,4705,4706,4707,4708,4709,4710,4711,4712,4713,4714,4715,4716,4717,4718,4719,4720,4721,4722,4723,4724,4725,4726,4727,4728,4729,4730,4731,4732,4733,4734,4735,4736,4737,4738,4739,4740,4741,4742,4743,4744,4745,4746,4747,4748,4749,4750,4751,4752,4753,4754,4755,4756,4757,4758,4759,4760,4761,4762,4763,4764,4765,4766,4767,4768,4769,4770,4771,4772,4773,4774,4775,4776,4777,4778,4779,4780,4781,4782,4783,4784,4785,4786,4787,4788,4789,4790,4791,4792,4793,4794,4795,4796,4797,4798,4799,4800,4801,4802],{"category":1139},{"category":113},{"category":4162},"AI",{"category":1607},{"category":3632},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":4162},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":2340},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":4199},"Security",{"category":4199},{"category":3632},{"category":3632},{"category":113},{"category":4199},{"category":113},{"category":2340},{"category":4199},{"category":1607},{"category":3632},{"category":920},{"category":4162},{"category":113},{"category":1607},{"category":2340},{"category":1607},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":920},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":1607},{"category":4243},"Career",{"category":4162},{"category":4162},{"category":3632},{"category":2340},{"category":3632},{"category":1607},{"category":1607},{"category":3632},{"category":1607},{"category":2340},{"category":1607},{"category":920},{"category":920},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":4162},{"category":2340},{"category":3632},{"category":920},{"category":920},{"category":920},{"category":113},{"category":1607},{"category":1607},{"category":113},{"category":1139},{"category":4162},{"category":920},{"category":920},{"category":4199},{"category":920},{"category":3632},{"category":4162},{"category":113},{"category":1607},{"category":113},{"category":2340},{"category":113},{"category":2340},{"category":4199},{"category":113},{"category":113},{"category":1607},{"category":3632},{"category":1607},{"category":1139},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":3632},{"category":3632},{"category":113},{"category":1139},{"category":4199},{"category":2340},{"category":4199},{"category":1139},{"category":1607},{"category":1607},{"category":920},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":920},{"category":1607},{"category":1607},{"category":113},{"category":113},{"category":4199},{"category":2340},{"category":2340},{"category":4243},{"category":4243},{"category":4243},{"category":3632},{"category":1607},{"category":920},{"category":2340},{"category":113},{"category":113},{"category":920},{"category":2340},{"category":2340},{"category":1139},{"category":1607},{"category":113},{"category":113},{"category":1607},{"category":113},{"category":920},{"category":920},{"category":113},{"category":4199},{"category":113},{"category":2340},{"category":4199},{"category":2340},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":4199},{"category":1607},{"category":920},{"category":920},{"category":3632},{"category":1607},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":2340},{"category":2340},{"category":2340},{"category":1607},{"category":113},{"category":113},{"category":113},{"category":920},{"category":3632},{"category":113},{"category":113},{"category":1607},{"category":113},{"category":1607},{"category":1139},{"category":113},{"category":3632},{"category":3632},{"category":1607},{"category":1607},{"category":4162},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":1607},{"category":920},{"category":920},{"category":920},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":113},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":3632},{"category":3632},{"category":113},{"category":1607},{"category":1139},{"category":2340},{"category":4243},{"category":113},{"category":113},{"category":4199},{"category":1607},{"category":113},{"category":113},{"category":920},{"category":113},{"category":1139},{"category":920},{"category":920},{"category":4199},{"category":1607},{"category":1607},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":4243},{"category":113},{"category":2340},{"category":1607},{"category":1607},{"category":113},{"category":920},{"category":113},{"category":113},{"category":113},{"category":1139},{"category":113},{"category":113},{"category":1607},{"category":113},{"category":1607},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":4162},{"category":4162},{"category":1607},{"category":113},{"category":920},{"category":920},{"category":113},{"category":1607},{"category":113},{"category":113},{"category":4162},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":1607},{"category":4199},{"category":1607},{"category":1607},{"category":1139},{"category":1607},{"category":1139},{"category":1139},{"category":4199},{"category":2340},{"category":1607},{"category":2340},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":1607},{"category":3632},{"category":1607},{"category":1607},{"category":113},{"category":2340},{"category":4162},{"category":4162},{"category":113},{"category":113},{"category":113},{"category":113},{"category":3632},{"category":1607},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":1139},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":1607},{"category":2340},{"category":113},{"category":3632},{"category":4162},{"category":113},{"category":3632},{"category":4199},{"category":113},{"category":4199},{"category":1607},{"category":920},{"category":113},{"category":113},{"category":1607},{"category":113},{"category":2340},{"category":113},{"category":113},{"category":1607},{"category":3632},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":3632},{"category":1607},{"category":1607},{"category":3632},{"category":920},{"category":1607},{"category":4162},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":113},{"category":113},{"category":113},{"category":4162},{"category":1607},{"category":1607},{"category":2340},{"category":1139},{"category":1607},{"category":113},{"category":1607},{"category":2340},{"category":3632},{"category":3632},{"category":1139},{"category":1139},{"category":113},{"category":3632},{"category":4199},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":1607},{"category":1607},{"category":2340},{"category":1607},{"category":1607},{"category":1607},{"category":4633},"Programming",{"category":1607},{"category":1607},{"category":2340},{"category":2340},{"category":1607},{"category":1607},{"category":3632},{"category":4199},{"category":1607},{"category":3632},{"category":1607},{"category":1607},{"category":1607},{"category":1607},{"category":920},{"category":2340},{"category":3632},{"category":3632},{"category":1607},{"category":1607},{"category":3632},{"category":1607},{"category":4199},{"category":3632},{"category":1607},{"category":1607},{"category":2340},{"category":2340},{"category":113},{"category":3632},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":113},{"category":1139},{"category":113},{"category":920},{"category":4199},{"category":4199},{"category":4199},{"category":4199},{"category":4199},{"category":4199},{"category":113},{"category":1607},{"category":920},{"category":2340},{"category":920},{"category":2340},{"category":1607},{"category":1139},{"category":113},{"category":2340},{"category":1139},{"category":113},{"category":113},{"category":113},{"category":2340},{"category":2340},{"category":2340},{"category":3632},{"category":3632},{"category":3632},{"category":2340},{"category":2340},{"category":3632},{"category":3632},{"category":3632},{"category":113},{"category":4199},{"category":1607},{"category":920},{"category":1607},{"category":113},{"category":3632},{"category":3632},{"category":113},{"category":113},{"category":2340},{"category":1607},{"category":2340},{"category":2340},{"category":2340},{"category":1139},{"category":1607},{"category":113},{"category":113},{"category":3632},{"category":3632},{"category":2340},{"category":1607},{"category":4243},{"category":2340},{"category":4243},{"category":3632},{"category":113},{"category":2340},{"category":113},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":113},{"category":4162},{"category":4162},{"category":920},{"category":113},{"category":113},{"category":113},{"category":113},{"category":1607},{"category":1607},{"category":1139},{"category":1607},{"category":4199},{"category":2340},{"category":1139},{"category":1139},{"category":1607},{"category":1607},{"category":1139},{"category":1139},{"category":1139},{"category":4199},{"category":1607},{"category":1607},{"category":3632},{"category":1607},{"category":2340},{"category":113},{"category":113},{"category":2340},{"category":113},{"category":113},{"category":2340},{"category":113},{"category":1607},{"category":113},{"category":4199},{"category":113},{"category":113},{"category":113},{"category":920},{"category":920},{"category":4199},1772951194593]