Brief

The what: A quick snapshot of the context

I was in a team meeting, while setting up a new Coroutine primitives module.

When our CTO said something that stopped me mid-keystroke.

"We lost around $30 000 on the latest issue, due to a lack of observability in the project."

I looked at the code I had open and realized:

"This is the place we'll lose the next $100 000."

The code I was looking at

Every tutorial recommends this CoroutineScope setup and most projects ship it exactly like this.

This isn't a bug.

It's the gap between knowing Coroutines and understanding them.

I call it: "Silent Exceptions" ...

🐞 The Issue: Nothing crashes. Features just die quietly.

Breakdown

The when: Reverse engineering the cause and the cost

Any operation you launch from a global scope - syncing data, running optimistic requests, parallel background work - can throw an exception that disappears.

Let me show you exactly how this type of issue reaches production.

And what it costs when it does.

Lost data = lost money $$$

I'll take a sync data operation as an example.

Let's say you need to display a promo banner on the Feed.

Usually, such data loading consists of two steps:

  1. Getting personalised configurations from the remote config:

{
  "offer_id": "intro_50_off", 
  "banner_type": "LARGE"
}
  1. Loading offer details from the BE:

class UpdateUserPromosUseCase(
    val configRepository: ConfigRepository,
    val promoRepository: PromoRepository
){
    suspend fun invoke() {
        val config = configRepository.getPromoBanner()

        promoRepository.updatePromoBanner(
           id = config.offer_id,
           type = config.banner_type
        )
    }
}

Since you don't want to increase app startup time - you'll launch this operation from the Application class.

In order to do that, you need to set up a global CoroutineScope:

@Module
class CoroutineScopeModule {
    @Singleton
    @Provides
    @Named("ApplicationScope")
    internal fun provideApplicationScope(
        @Named("IODispatcher")
        dispatcher: CoroutineDispatcher,
    ): CoroutineScope = CoroutineScope(dispatcher + SupervisorJob())
}

Then use it to start an asynchronous update operation:

class MyApplication : Application() {

   //.... dependencies ...

    override fun onCreate() {
        applicationScope.launch { 
            updateUserPromoUseCase() 
        }
    }
}

Nothing suspicious. Right?

You build it. You test it. You ship it.

Your users use it for a couple of weeks.

Then on Friday evening, your PO decides to run a small A/B test.

He updates the remote config for a specific user segment:

{
  "offer_id": "intro_50_off", 
  "banner_type": "SMAL"
}

The setup was fine, except for a little typo: SMALLSMAL .

(The app silently threw a serialisation exception trying to parse an unknown enum value)

On Monday morning he checks the analytics - nothing changed.

He asks devs: "Any crashes?" - "No crashes. The app is stable."

He asks QA: "Any issues on prod?" - "Banner displayed fine on dev and prod."

Because the test had a niche setup tied to user location, no one could reproduce it.

You spend a full day until you figure out the config issue.

Time and budget burned.

SupervisorJob protects your app. Not your features.

Here's what happens every time an exception fires in this setup:

  • An Exception occurs and the request fails

  • Exception propagates up to the SupervisorJob

  • SupervisorJob sees it, isolates it, doesn't cancel siblings

  • Looks for a CoroutineExceptionHandler in the context

  • Finds none

  • Passes it to the platform's last-resort handler

  • The exception is silently swallowed

→ Feature never displayed.

This context has two elements when it ALWAYS needs three:

  1. Dispatcher tells coroutines where to run

  2. Job tells them how to fail

  3. ExceptionHandler makes unhandled exceptions handled

SupervisorJob does exactly one thing — it prevents a failing coroutine from cancelling its siblings. That's it.

It doesn't catch the exception. It doesn't log it. It doesn't surface it anywhere.

It just isolates it. And lets it disappear.

The Fix: Add CoroutineExceptionHandler into the Context and dispatch exceptions to observability tools.

Guideline

The how: Implementation guideline and recommendations

Make exceptions visible

The fix is simple and consists of just 3 steps.

Step 1 - Set up

Provide exception handler as a standalone dependency.

This way you can reuse its logging for other scopes as well.

@Retention
@Qualifier
annotation class ExceptionHandler {
    @Retention
    @Qualifier
    annotation class Log
}

@Module
class CoroutineExceptionHandlerModule {
    @Provides
    @Singleton
    @ExceptionHandler.Log
    fun providesLogExceptionHandler(): CoroutineExceptionHandler = 
        CoroutineExceptionHandler { _, exception ->
           //observability entry point here
        }
}

Use annotation class instead of @Named qualifier for type-safety.

Step 2 - Catch

Add the exception handler to the coroutine scope context:

@Retention
@Qualifier
annotation class Scope {
    @Retention
    @Qualifier
    annotation class IO
}

@Module
class CoroutineScopesModule {
    @Provides
    @Singleton
    @Scope.IO
    fun providesIoCoroutineScope(
        @Dispatcher.Io dispatcher: CoroutineDispatcher,
        @ExceptionHandler.Log exceptionHandler: CoroutineExceptionHandler
    ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher + exceptionHandler) 
}

Step 3 — Observe

Dispatch the collected exception to the observability tool you're using.

It could be a simple Log, Firebase Crashlytics, Grafana, DataDog, etc.

 CoroutineExceptionHandler { _, exception ->
   //Log
   log(Level.SEVERE, "Unhandled coroutine exception", exception)
   //Or report
   Crashlytics.recordException(exception)
}

One handler at scope level. Full coverage.

Nothing slips through silently again.

Let the AI agent do it

I already spent the time so you don't have to.

This isn't a "fix it" prompt - it's a full audit turned into a precise agent workflow.

You get a scanned codebase, confirmed changes, and clean implementation:

Use this issue as the implementation guideline:
👉 [ISSUE_LINK]

⛔ Do not proceed to the next step without explicit user confirmation.

⛔ Make sure user inserted ISSUE_LINK 

⛔ If you can't process ISSUE_LINK ask user to copy-paste newsletter text


🔎 Step 1 — Observability

Scan for observability SDKs (Crashlytics, DataDog, Grafana, etc).

Present findings as a list and ask the user which one to use.

If none found, suggest adding one or default to Logcat.


🐞 Step 2 — Identify

Scan for all CoroutineScopes missing a CoroutineExceptionHandler.

Present results as a table: file path | scope name | missing element.

Ask the user to confirm before proceeding.


✅ Step 3 — Fix

Follow the Guideline section from the link above EXACTLY.

Implement each change directly in the codebase.

If any blocker is found, ask for clarification.

Pitch

The why: How to get it approved and get recognized

Before you do anything

Don't just forward this issue to your team.

That's the entry-level move.

The senior engineer move is showing up with numbers.

Spend 10 minutes in your codebase first:

  1. Find the affected scopes - search for CoroutineScope missing a CoroutineExceptionHandler

  2. Pick 2–3 critical flows - payment, data sync, promos, onboarding - anything where a silent failure costs something real

  3. Put a number on it - "If our payment job fails silently, that's roughly $X in unprocessed transactions"

You don't need exact numbers. A reasonable estimate is enough.

Leverage on AI research

10x engineers don't grep through the codebase manually anymore.

They delegate the research and show up with numbers.

This prompt scans your codebase for the issue above and returns a risk table ready to drop into the message below.

Use this issue as the context for the research:
👉 [INSSUE_LINK]

⛔ Make sure user has inserted ISSUE_LINK.

⛔ If you can't process ISSUE_LINK, ask user to copy-paste the newsletter text.


💰 Find the risk

Scan the codebase for the issue described in the ISSUE_LINK "Brief" and "Breakdown" sections.

For each affected scope identify the business operation it runs.

Present results as a table: reference | scope name | business operation | issue cost (~X [unit] per [day/week]).

The prompt output example

The message

This is your script. Send it to whoever needs to approve the fix - team lead, manager, or the whole team chat.

Hey [name],

I found a silent exceptions risk in our codebase I want to flag.

Our global workflows are missing observability. Any exception thrown from that jobs gets silently swallowed — no crash, no log, no alert. The app looks healthy while features quietly stop working.

I checked our most critical flows. [Flow 1] and [Flow 2] are both affected. Based on [your estimate], the risk is roughly [number] per [day/week] if left unresolved.

The fix is three lines of code and takes less than an hour. I'd like to prioritise it in the next sprint.

Happy to walk you through it if helpful.

→ You walk in as the engineer who found the problem, quantified it, and came with the solution ready.

That's not just a fix. That's a promotion conversation waiting to happen 😎

Explore

The where: Related resources and topics

I mentioned annotation class for DI qualifiers earlier in this issue. This is where I first wrote it all out - worth a look if you want the full picture.

It doesn't always fire when you expect it to. Worth reading through the pitfalls section before you ship anything.

Hidden exception handling strategy most Android devs never really explore. Worth knowing - but watch out for the coroutines pitfalls.

So here's my question to you this week:

Do you trust your crash rate? Reply yes or no — and one sentence on why. I'll publish the split next issue.

Keep Reading