[{"data":1,"prerenderedAt":774},["ShallowReactive",2],{"home-featured":3,"home-posts":448},[4,186,349],{"id":5,"title":6,"body":7,"cover":158,"coverFit":159,"description":160,"extension":161,"featured":162,"liveUrl":163,"markets":164,"meta":165,"metric":166,"metricLabel":167,"navigation":162,"order":168,"path":169,"period":170,"role":171,"screenshots":172,"seo":175,"stack":176,"stem":183,"tagline":184,"__hash__":185},"work\u002Fwork\u002Fbetzetu.md","BetZetu",{"type":8,"value":9,"toc":149},"minimark",[10,15,24,27,30,34,37,60,64,75,81,87,93,96,100,117,121,142,146],[11,12,14],"h2",{"id":13},"context","Context",[16,17,18,19,23],"p",{},"Betting platforms are an attractive target. The money moves fast, the account lifecycle is short,\nand the abuse patterns — bonus farming, collusion between accounts, deposit-and-withdraw laundering\ncycles — only become visible when you look at behaviour ",[20,21,22],"em",{},"across"," events rather than at one\ntransaction in isolation.",[16,25,26],{},"The platform already had fraud rules. They ran as scheduled batch jobs against the transactional\ndatabase, which meant two problems: the checks were competing with live traffic for database\ncapacity, and by the time a pattern surfaced, the money was usually gone.",[16,28,29],{},"Detection needed to move off the database and onto the event stream.",[11,31,33],{"id":32},"the-problem","The problem",[16,35,36],{},"Three constraints shaped everything:",[38,39,40,48,54],"ul",{},[41,42,43,47],"li",{},[44,45,46],"strong",{},"Volume."," Peak traffic produces well over 50,000 relevant events a minute — bet placements,\nsettlements, deposits, withdrawals, logins, profile edits.",[41,49,50,53],{},[44,51,52],{},"Latency."," A decision that arrives after the withdrawal has cleared is a report, not a control.\nTo actually block a transaction, the verdict has to come back inside the request window.",[41,55,56,59],{},[44,57,58],{},"Statefulness."," The interesting signals aren't per-event. They're \"this account has made 14\ndeposits from 5 different instruments in 20 minutes\" — which means holding windowed state across\na stream, per account, continuously.",[11,61,63],{"id":62},"architecture","Architecture",[65,66,71],"pre",{"className":67,"code":69,"language":70},[68],"language-text","services ──▶ Kafka ──▶ Flink ──▶ decision API ──▶ platform\n             (log)     (state)        │\n                                      └──▶ flagged ──▶ manual review\n","text",[72,73,69],"code",{"__ignoreMap":74},"",[16,76,77,80],{},[44,78,79],{},"Kafka"," as the ingest log. Every service publishes domain events to partitioned topics keyed by\naccount ID, which gives two things for free: ordering guarantees per account, and horizontal scale\nby adding partitions and consumers. Because the log is durable and replayable, a change to the\ndetection logic can be validated against real historical traffic before it goes anywhere near\nproduction.",[16,82,83,86],{},[44,84,85],{},"Flink"," for the stateful layer. Sliding windows per account track deposit velocity, stake\npatterns, win-rate anomalies and device\u002FIP overlap between accounts. Flink's checkpointing is what\nmakes this survivable — a task manager restart resumes from the last checkpoint instead of losing\nthe window and starting cold.",[16,88,89,92],{},[44,90,91],{},"A decision service"," consuming the scored output and returning an allow\u002Fblock verdict on the\nsynchronous path, with Redis holding hot account state so the common case never touches Postgres.",[16,94,95],{},"Anything scored above the threshold diverts to a review queue rather than being auto-blocked\noutright — a false positive that freezes a legitimate customer's withdrawal is its own kind of\nexpensive.",[11,97,99],{"id":98},"what-i-built","What I built",[38,101,102,105,108,111,114],{},[41,103,104],{},"Event schemas and the Kafka topic topology — partitioning strategy, retention, and consumer group\nlayout for independent scaling of each consumer.",[41,106,107],{},"Flink jobs for the windowed aggregations, including watermark and late-arrival handling, since\nmobile clients on unreliable connections deliver events out of order routinely.",[41,109,110],{},"The decision service on the synchronous path, with a Redis cache for hot account state.",[41,112,113],{},"Consumer resilience — dead-letter handling for malformed payloads, and an offset strategy that\ndoesn't replay a poison message into a crash loop on every restart.",[41,115,116],{},"Kubernetes deployment across multiple clusters, with the throughput and lag dashboards needed to\ntell \"the pipeline is healthy\" from \"the pipeline is quietly falling behind\".",[11,118,120],{"id":119},"impact","Impact",[38,122,123,130,136,139],{},[41,124,125,126,129],{},"Sustains ",[44,127,128],{},"50,000+ events per minute"," through the pipeline.",[41,131,132,135],{},[44,133,134],{},"Sub-200ms p99"," end-to-end, fast enough to sit on the synchronous path.",[41,137,138],{},"Fraud checks moved entirely off the transactional database, removing a recurring source of\ncontention during peak hours.",[41,140,141],{},"Detection logic can be replayed against historical traffic before release, so a rule change is\nsomething you can measure rather than hope about.",[11,143,145],{"id":144},"what-id-revisit","What I'd revisit",[16,147,148],{},"The windowing thresholds were tuned empirically and encode assumptions about traffic that shift by\nmarket. The next step is making them adaptive per market rather than globally configured — Nairobi\nat 9pm and Lagos at 9pm are not the same distribution, and treating them as one costs you either\nrecall or false positives.",{"title":74,"searchDepth":150,"depth":150,"links":151},2,[152,153,154,155,156,157],{"id":13,"depth":150,"text":14},{"id":32,"depth":150,"text":33},{"id":62,"depth":150,"text":63},{"id":98,"depth":150,"text":99},{"id":119,"depth":150,"text":120},{"id":144,"depth":150,"text":145},"\u002Fimages\u002Fwork\u002Fbetzetu.png","cover","A Kafka and Flink pipeline that scores every bet, deposit and withdrawal as it happens — 50K+ events per minute, decisions back in under 200ms at p99.","md",true,"https:\u002F\u002Fwww.betzetu.com\u002F",[],{},"50K+","events \u002F minute",1,"\u002Fwork\u002Fbetzetu","2024 — 2026","Backend engineering · streaming pipeline",[173],{"src":158,"caption":174},"Sportsbook home — live markets, betslip and the highlights feed",{"title":6,"description":160},[177,178,179,180,181,182],"Go","Apache Kafka","Apache Flink","PostgreSQL","Redis","Kubernetes","work\u002Fbetzetu","Real-time fraud detection on a live betting platform","b_p68N_YoS8Cld0tgJLrG1AA96zC2lObrsRHVGdO9mg",{"id":187,"title":188,"body":189,"cover":320,"coverFit":321,"description":322,"extension":161,"featured":162,"liveUrl":323,"markets":324,"meta":331,"metric":332,"metricLabel":333,"navigation":162,"order":150,"path":334,"period":170,"role":335,"screenshots":336,"seo":339,"stack":340,"stem":346,"tagline":347,"__hash__":348},"work\u002Fwork\u002Fbandabets.md","BandaBets",{"type":8,"value":190,"toc":312},[191,193,196,199,201,204,210,216,223,225,228,260,263,266,269,271,288,290,307,309],[11,192,14],{"id":13},[16,194,195],{},"BandaBets runs in six countries — Kenya, Nigeria, Uganda, Zambia, Malawi and the DRC — off a single\nfrontend platform. Each market brings its own currency, its own payment providers, its own\nregulator and, in several cases, its own language.",[16,197,198],{},"The codebase had grown the way frontends do: one Nuxt application that started small, accumulated a\nsportsbook, a casino, a payments flow, an account area and six markets' worth of localisation, and\nnever got reorganised along the way.",[11,200,33],{"id":32},[16,202,203],{},"Two problems, and they compounded each other.",[16,205,206,209],{},[44,207,208],{},"The deployment unit."," Everything shipped together, so everything carried the risk of everything\nelse. A one-line copy change meant rebuilding and redeploying the whole platform. That produces a\npredictable spiral: releases get batched because they're risky, batches get bigger, bigger batches\nare riskier, and shipping slows until the frontend is the bottleneck for work that was finished days\nago. Two engineers touching different features regularly collided in the same files.",[16,211,212,215],{},[44,213,214],{},"The markets."," Multi-market platforms usually fail in one of two directions. Fork per market and\nyou get six codebases drifting apart, where every bug fix is six pull requests and the sixth one\ngets forgotten. Hardcode one market and bolt the others on, and every new country becomes an\narchaeology expedition through assumptions nobody wrote down — that the currency has two decimal\nplaces, that phone numbers start with a particular prefix, that the copy fits the button.",[16,217,218,219,222],{},"The goal was one deployable platform where ",[44,220,221],{},"a market is a configuration, not a branch",".",[11,224,63],{"id":62},[16,226,227],{},"The split followed the product's natural seams rather than a technical taxonomy:",[38,229,230,236,242,248,254],{},[41,231,232,235],{},[44,233,234],{},"Sportsbook"," — fixtures, odds, bet slip, live updates",[41,237,238,241],{},[44,239,240],{},"Casino"," — game catalogue, provider integrations, session handling",[41,243,244,247],{},[44,245,246],{},"Wallet & payments"," — deposits, withdrawals, transaction history",[41,249,250,253],{},[44,251,252],{},"Account"," — auth, profile, verification, responsible-gaming controls",[41,255,256,259],{},[44,257,258],{},"Shared core"," — design system, i18n, session context, API client",[16,261,262],{},"Each domain became an independently buildable and deployable Nuxt layer with its own routes and\ncomponents. The shared core was extracted into a versioned internal package, so common UI and the\nAPI client had exactly one definition rather than several slightly diverged copies.",[16,264,265],{},"Layers were the right primitive here specifically because they compose at build time — the user\nstill gets one coherent application and one navigation, not six apps stitched together with full\npage reloads.",[16,267,268],{},"Market differences live in configuration: currency and locale per deployment, payment providers\nresolved per market, and locale bundles loaded on demand so a user in Nairobi never downloads French\ncopy. Layouts were built to survive translation — Swahili strings routinely run longer than the\nEnglish they replace, and a fixed-width button is a rendering bug waiting for a translator.",[11,270,99],{"id":98},[38,272,273,276,279,282,285],{},[41,274,275],{},"The module boundaries, and the migration path that moved existing code across them without\nfreezing feature work — the split happened incrementally alongside ongoing delivery.",[41,277,278],{},"The shared design-system and i18n package, covering English, Swahili and French.",[41,280,281],{},"Per-module CI pipelines, so a change to the casino builds and deploys the casino.",[41,283,284],{},"Backend services supporting the split — API contracts scoped per domain instead of one endpoint\nreturning everything any screen might need.",[41,286,287],{},"Rendering strategy per route: static generation for marketing and catalogue pages, server-side\nrendering for anything session- or odds-dependent.",[11,289,120],{"id":119},[38,291,292,298,301,304],{},[41,293,294,297],{},[44,295,296],{},"Shipping time down 25%."," Mostly structural rather than clever: smaller deployment units mean a\nsmaller blast radius, which means less batching and less pre-release ceremony.",[41,299,300],{},"Multiple engineers work in parallel without merge collisions in shared files.",[41,302,303],{},"Acquisition routes prerender to static HTML — a real difference on the mid-range Android devices\nthat make up most of the traffic.",[41,305,306],{},"Adding a market became configuration and a provider integration rather than a fork.",[11,308,145],{"id":144},[16,310,311],{},"The shared core is doing a lot of work, and shared packages have gravity — every convenience added\nto one makes it harder to change. It needs an explicit rule about what earns a place in it, enforced\nin review rather than left to judgement, or it slowly becomes the monolith it replaced.",{"title":74,"searchDepth":150,"depth":150,"links":313},[314,315,316,317,318,319],{"id":13,"depth":150,"text":14},{"id":32,"depth":150,"text":33},{"id":62,"depth":150,"text":63},{"id":98,"depth":150,"text":99},{"id":119,"depth":150,"text":120},{"id":144,"depth":150,"text":145},"\u002Fimages\u002Fwork\u002Fbandabets.png","contain","Re-architected a monolithic Nuxt frontend into modular, independently deployable services across six African markets — shipping time cut 25%.","https:\u002F\u002Fke.bandabets.com\u002F",[325,326,327,328,329,330],"Kenya","Nigeria","Uganda","Zambia","Malawi","DRC",{},"25%","faster shipping","\u002Fwork\u002Fbandabets","Frontend architecture · backend services",[337],{"src":320,"caption":338},"Mobile web — the surface most of the traffic actually arrives on",{"title":188,"description":322},[341,342,343,344,345,182],"Nuxt","Vue 3","TypeScript","Node.js","Docker","work\u002Fbandabets","One frontend platform, six countries, shipped by a team that stopped colliding","IDMpyKv0xhgcscuywNWGYf_DxgS8PvMzMTuqPV3YqyU",{"id":350,"title":351,"body":352,"cover":428,"coverFit":159,"description":429,"extension":161,"featured":162,"liveUrl":430,"markets":431,"meta":432,"metric":433,"metricLabel":434,"navigation":162,"order":435,"path":436,"period":170,"role":437,"screenshots":438,"seo":441,"stack":442,"stem":445,"tagline":446,"__hash__":447},"work\u002Fwork\u002Fnetbet.md","NetBet",{"type":8,"value":353,"toc":421},[354,356,359,362,364,367,374,377,379,385,391,397,403,405,416,418],[11,355,14],{"id":13},[16,357,358],{},"NetBet is the Uganda deployment: a full sportsbook and casino serving a single market. I worked on\nthe backend — the services behind accounts, betting, settlement and the wallet, plus the payment\nintegrations that connect it to how people in Uganda actually move money.",[16,360,361],{},"A single-market platform sounds simpler than a multi-country one, and in one sense it is. What it\nremoves in configuration complexity it hands back in specificity: one currency, one regulator, one\nset of payment rails, and no ability to hide a bad assumption behind a per-market override.",[11,363,33],{"id":32},[16,365,366],{},"The interesting constraints were on the money path.",[16,368,369,370,373],{},"Most deposits and withdrawals here run through ",[44,371,372],{},"mobile money",", not cards. That changes the failure\nmodel. A card gateway either authorises or declines, usually inside a second. A mobile-money\ntransaction is asynchronous: the platform initiates, the user confirms on their handset, and a\ncallback arrives at some point afterwards — or doesn't, or arrives twice, or arrives after the user\nhas already given up and retried.",[16,375,376],{},"Any wallet built on the assumption that a payment resolves synchronously will eventually\ndouble-credit somebody or lose a deposit. Those are the two failure modes that actually matter,\nbecause one costs money and the other costs trust.",[11,378,99],{"id":98},[16,380,381,384],{},[44,382,383],{},"Payments."," Provider integrations for deposits and withdrawals against local mobile-money rails,\nbuilt around the assumption that callbacks are unreliable by default. Every transaction carries an\nidempotency key, so a provider retrying a webhook credits an account once and not three times.\nCallback signatures are verified rather than trusted. Pending transactions reconcile against the\nprovider rather than sitting in limbo waiting for a callback that may never arrive.",[16,386,387,390],{},[44,388,389],{},"The ledger."," Amounts stored in minor units against an explicit currency, never as floats.\nUGX has its own conventions, and a ledger that is vague about what a number means is a ledger that\nwill eventually be wrong in a way nobody can reconstruct.",[16,392,393,396],{},[44,394,395],{},"Services."," Go and Spring Boot services for accounts, betting, settlement and wallet operations,\ncommunicating over gRPC internally and REST at the edge — the same service patterns as the rest of\nthe platform stack, so operational knowledge transfers between markets instead of being relearned.",[16,398,399,402],{},[44,400,401],{},"Streaming."," Domain events published to Kafka, feeding the same downstream consumers used for\nmonitoring and fraud detection elsewhere on the stack.",[11,404,120],{"id":119},[38,406,407,410,413],{},[41,408,409],{},"A live sportsbook and casino running in Uganda on the shared platform stack.",[41,411,412],{},"Payment flows that survive the realities of mobile money — retried callbacks, delayed\nconfirmations and user retries — without double-crediting or silently dropping deposits.",[41,414,415],{},"Backend patterns consistent with the other markets, so a fix made once applies broadly rather than\nbeing reimplemented per country.",[11,417,145],{"id":144},[16,419,420],{},"Provider integrations were built one at a time as each rail came online, which left more shape in\ncommon between them than the code currently admits. A single provider abstraction with\nper-provider adapters would make the next integration substantially cheaper — the pattern was\nvisible by the second one, and that was the moment to consolidate.",{"title":74,"searchDepth":150,"depth":150,"links":422},[423,424,425,426,427],{"id":13,"depth":150,"text":14},{"id":32,"depth":150,"text":33},{"id":98,"depth":150,"text":99},{"id":119,"depth":150,"text":120},{"id":144,"depth":150,"text":145},"\u002Fimages\u002Fwork\u002Fnetbet.png","Backend services for a live sportsbook in Uganda — mobile-money payments built to survive unreliable callbacks, and a UGX-denominated ledger.","https:\u002F\u002Fnetbetug.com",[327],{},"UG","Uganda · live platform",3,"\u002Fwork\u002Fnetbet","Backend engineering",[439],{"src":428,"caption":440},"Sportsbook home — markets, betslip and the live feed",{"title":351,"description":429},[177,443,444,180,178,181],"Java","Spring Boot","work\u002Fnetbet","Backend engineering for the Uganda market","4O6uSaed32B8awgaAlXSgNwuo-atFXHfrWr-icfc42I",[449],{"id":450,"title":451,"body":452,"date":762,"description":763,"draft":764,"extension":161,"meta":765,"navigation":162,"path":766,"readingTime":767,"seo":768,"stem":769,"tags":770,"__hash__":773},"writing\u002Fwriting\u002Fkafka-consumer-crash-loop.md","The consumer that wouldn't stay up",{"type":8,"value":453,"toc":755},[454,457,460,464,467,470,473,476,484,490,499,513,523,532,536,539,549,701,712,718,732,736,739,745,751],[16,455,456],{},"A consumer group starts crashing. It restarts, processes for a few seconds, and crashes again.\nLag climbs. Restarting the pods doesn't help — which is the first genuinely useful piece of\ninformation, because it rules out most of what you'd normally suspect.",[16,458,459],{},"This is a note about what that failure actually is, and why the fix isn't where people usually\nreach first.",[11,461,463],{"id":462},"the-shape-of-the-problem","The shape of the problem",[16,465,466],{},"A Kafka consumer commits offsets to record how far it has read. On restart, it resumes from the\nlast committed offset. That's the whole point — it's what makes consumers restartable.",[16,468,469],{},"Now suppose a message arrives that the consumer can't deserialise. Maybe a producer shipped a\nschema change. Maybe a field that was never null is null. The deserialiser throws, the exception\npropagates, the consumer dies before committing.",[16,471,472],{},"It restarts, resumes from the last committed offset, reads the same message, and dies again.",[16,474,475],{},"That's the loop. Nothing is flapping, nothing is under-resourced, and no amount of restarting\nhelps — the consumer is behaving exactly as designed. It's just that \"resume where you left off\"\nand \"you cannot get past this message\" combine into a system that makes no progress forever.",[11,477,479,480,483],{"id":478},"why-autooffsetreset-gets-blamed","Why ",[72,481,482],{},"auto.offset.reset"," gets blamed",[16,485,486,487,489],{},"Somebody always suggests changing ",[72,488,482],{},". It's worth being precise about what that\nsetting does, because the confusion is the interesting part.",[16,491,492,494,495,498],{},[72,493,482],{}," only applies when there is ",[44,496,497],{},"no valid committed offset"," — a brand new consumer\ngroup, or a committed offset that has aged out of the topic's retention. It has two useful values:",[38,500,501,507],{},[41,502,503,506],{},[72,504,505],{},"earliest"," — start from the beginning of the topic",[41,508,509,512],{},[72,510,511],{},"latest"," — start from the next message produced",[16,514,515,516,518,519,522],{},"Set it to ",[72,517,505],{}," and a consumer group with no committed offset replays the whole topic from the\nstart. If a poison message sits anywhere in that history, you've guaranteed you hit it — and if the\ncrash also prevents the offset commit, the group never establishes a valid offset, so the ",[20,520,521],{},"next","\nrestart takes the reset path again and replays from the beginning again.",[16,524,525,526,528,529],{},"That's the version of this bug that looks like ",[72,527,482],{}," is at fault. It isn't. It's\namplifying the real problem, which is that ",[44,530,531],{},"a deserialisation failure is being treated as fatal.",[11,533,535],{"id":534},"the-actual-fix","The actual fix",[16,537,538],{},"Three changes, in order of how much they matter.",[16,540,541,544,545,548],{},[44,542,543],{},"1. Make deserialisation failures survivable."," A message the consumer can't parse should be\nrouted somewhere and skipped, not allowed to kill the poll loop. In Kafka this is what an\n",[72,546,547],{},"ErrorHandlingDeserializer"," and a dead-letter topic are for:",[65,550,554],{"className":551,"code":552,"language":553,"meta":74,"style":74},"language-java shiki shiki-themes github-dark-dimmed","@Bean\npublic DefaultErrorHandler errorHandler(KafkaTemplate\u003CString, String> template) {\n    \u002F\u002F after retries are exhausted, publish to \u003Ctopic>.DLT and move on\n    var recoverer = new DeadLetterPublishingRecoverer(template);\n    var handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1_000L, 2));\n\n    \u002F\u002F never retry what will never succeed — a bad payload is bad on attempt three too\n    handler.addNotRetryableExceptions(DeserializationException.class);\n    return handler;\n}\n","java",[72,555,556,568,595,601,622,662,668,674,686,695],{"__ignoreMap":74},[557,558,560,564],"span",{"class":559,"line":168},"line",[557,561,563],{"class":562},"s1ain","@",[557,565,567],{"class":566},"scLw-","Bean\n",[557,569,570,573,576,580,583,586,589,592],{"class":559,"line":150},[557,571,572],{"class":566},"public",[557,574,575],{"class":562}," DefaultErrorHandler ",[557,577,579],{"class":578},"sLyBy","errorHandler",[557,581,582],{"class":562},"(KafkaTemplate",[557,584,585],{"class":566},"\u003C",[557,587,588],{"class":562},"String, String",[557,590,591],{"class":566},">",[557,593,594],{"class":562}," template) {\n",[557,596,597],{"class":559,"line":435},[557,598,600],{"class":599},"sVrTk","    \u002F\u002F after retries are exhausted, publish to \u003Ctopic>.DLT and move on\n",[557,602,604,607,610,613,616,619],{"class":559,"line":603},4,[557,605,606],{"class":566},"    var",[557,608,609],{"class":562}," recoverer",[557,611,612],{"class":566}," =",[557,614,615],{"class":566}," new",[557,617,618],{"class":578}," DeadLetterPublishingRecoverer",[557,620,621],{"class":562},"(template);\n",[557,623,625,627,630,632,634,637,640,643,646,649,653,656,659],{"class":559,"line":624},5,[557,626,606],{"class":566},[557,628,629],{"class":562}," handler",[557,631,612],{"class":566},[557,633,615],{"class":566},[557,635,636],{"class":578}," DefaultErrorHandler",[557,638,639],{"class":562},"(recoverer, ",[557,641,642],{"class":566},"new",[557,644,645],{"class":578}," FixedBackOff",[557,647,648],{"class":562},"(",[557,650,652],{"class":651},"sDGpf","1_000L",[557,654,655],{"class":562},", ",[557,657,658],{"class":651},"2",[557,660,661],{"class":562},"));\n",[557,663,665],{"class":559,"line":664},6,[557,666,667],{"emptyLinePlaceholder":162},"\n",[557,669,671],{"class":559,"line":670},7,[557,672,673],{"class":599},"    \u002F\u002F never retry what will never succeed — a bad payload is bad on attempt three too\n",[557,675,677,680,683],{"class":559,"line":676},8,[557,678,679],{"class":562},"    handler.",[557,681,682],{"class":578},"addNotRetryableExceptions",[557,684,685],{"class":562},"(DeserializationException.class);\n",[557,687,689,692],{"class":559,"line":688},9,[557,690,691],{"class":566},"    return",[557,693,694],{"class":562}," handler;\n",[557,696,698],{"class":559,"line":697},10,[557,699,700],{"class":562},"}\n",[16,702,703,704,707,708,711],{},"The distinction that matters: ",[44,705,706],{},"retryable"," failures (the database was briefly unavailable) should\nback off and retry. ",[44,709,710],{},"Non-retryable"," failures (this payload will never parse) should go straight\nto the dead-letter topic. Retrying a malformed message is just a slower crash loop.",[16,713,714,717],{},[44,715,716],{},"2. Commit deliberately."," Auto-commit makes the failure boundary fuzzy — you can't easily reason\nabout what was processed versus what was acknowledged. Manual acknowledgement after successful\nprocessing makes \"did this message count?\" a question with an answer.",[16,719,720,725,726,728,729,731],{},[44,721,722,723,222],{},"3. Then think about ",[72,724,482],{}," With the first two in place, this becomes what it\nshould have been all along: a policy decision about cold starts, not a lever for incident response.\nFor a consumer where missing historical data is worse than reprocessing it, ",[72,727,505],{}," is right.\nFor one where stale events are useless, ",[72,730,511],{}," is right. Neither choice can produce a crash loop\nonce bad payloads have somewhere to go.",[11,733,735],{"id":734},"what-to-keep","What to keep",[16,737,738],{},"Two things worth carrying forward.",[16,740,741,744],{},[44,742,743],{},"A poison message is a design gap, not an anomaly."," Any consumer without an explicit answer for\n\"what happens when this message can't be parsed\" has a crash loop waiting for the right input.",[16,746,747,750],{},[44,748,749],{},"Watch consumer lag, not consumer health."," A crash-looping consumer often looks fine to a\nliveness probe — it starts, it's alive, it serves the health endpoint, it dies, it starts again.\nLag is what tells you it isn't actually getting anywhere.",[752,753,754],"style",{},"html pre.shiki code .s1ain, html code.shiki .s1ain{--shiki-default:#ADBAC7}html pre.shiki code .scLw-, html code.shiki .scLw-{--shiki-default:#F47067}html pre.shiki code .sLyBy, html code.shiki .sLyBy{--shiki-default:#DCBDFB}html pre.shiki code .sVrTk, html code.shiki .sVrTk{--shiki-default:#768390}html pre.shiki code .sDGpf, html code.shiki .sDGpf{--shiki-default:#6CB6FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":74,"searchDepth":150,"depth":150,"links":756},[757,758,760,761],{"id":462,"depth":150,"text":463},{"id":478,"depth":150,"text":759},"Why auto.offset.reset gets blamed",{"id":534,"depth":150,"text":535},{"id":734,"depth":150,"text":735},"2026-05-18","A Kafka consumer group stuck in a restart loop, one malformed message, and why auto.offset.reset is the wrong place to look for a fix.",false,{},"\u002Fwriting\u002Fkafka-consumer-crash-loop","6 min read",{"title":451,"description":763},"writing\u002Fkafka-consumer-crash-loop",[79,771,772],"Debugging","Streaming","76xbGl3Lhx5ldvYgIRWRhc0RstGKhpWBf2nyQWrdd8E",1785591010220]