Sid Ngeth's Blog A blog about anything (but mostly development)

why your brain actually wants you to struggle

Your brain has this weird thing where it actually wants you to struggle. Not the soul-crushing, life-ending kind… the manageable “this is just hard enough to make me sweat” kind.

After 20 years of lifting and 6 years competing in powerlifting, I’ve realized something interesting. The iron doesn’t just forge stronger bodies - it builds antifragile minds. Controlled challenge exposure does something crazy - it rewires your entire system.

When you face manageable challenges, your brain literally changes structure. Neuroimaging studies show controlled stress increases prefrontal cortex volume. Diffusion tensor imaging reveals enhanced white matter myelination in executive control areas, creating faster neural highways between thinking and emotional centers.

your stress system becomes precision tuned

The hypothalamic-pituitary-adrenal axis (yeah that’s a mouthful) develops crazy adaptability through repeated controlled challenges. People who experience manageable stress develop enhanced glucocorticoid receptor sensitivity… basically more precise cortisol regulation. Their stress response becomes like a finely-tuned instrument.

Neurotransmitter systems coordinate too. Dopamine learns to find rewarding aspects in challenging situations. Serotonin adapts for mood regulation during stress. Norepinephrine optimizes release patterns - moderate levels engage high-affinity receptors that strengthen prefrontal function without the excessive release that screws up cognitive control.

stress inoculation actually works??

Stress inoculation training operates on an immunization model. Expose people to manageable doses of stress, build psychological immunity. Meta-analyses show large effect sizes across populations from military to anxiety patients.

Fear extinction creates new safety memories that compete with original fear memories. You’re not erasing stress responses but building a more complex, adaptive system. 65 studies with nearly 5,000 ptsd patients showed medium to large effects from exposure therapy, benefits maintained long-term.

The process involves implicit habituation and explicit skill development. Prefrontal cortex learns to regulate amygdala fear responses better while hippocampus improves contextual processing.

the 4% sweet spot (powerlifting gets this right)

Optimal growth happens when challenges are roughly 4% beyond current skill level. This maintains the flow channel - enough challenge to engage full attention without triggering overwhelm.

Powerlifting naturally implements this through systematic progression. Adding 2.5-5 pounds to a 400lb squat? That’s about 1% increase. Most good programs cycle between 70-95% of max, keeping you in that optimal challenge zone. It’s stress inoculation training disguised as sport.

4% challenge increase triggers ideal dopamine and norepinephrine levels. Enough to enhance focus without impairing prefrontal function. Maintains growth mindset where challenges become opportunities rather than threats.

This is why heavy singles work so well psychologically. When you’re regularly handling 92-93% in training, that 90% competition opener feels routine instead of terrifying. Your nervous system adapts to the stress pattern.

building antifragile systems (not just resilient ones)

Taleb’s antifragility concept is fascinating - systems that actually improve from volatility and stress. Unlike resilience (bouncing back) or robustness (resisting change), antifragile systems get stronger.

Powerlifting exemplifies this perfectly. You literally damage muscle fibers to promote growth through supercompensation. Each training cycle involves embracing uncertainty (will I make this lift?), processing failure (missed attempts provide feedback), and emerging stronger both physically and psychologically.

Elite powerlifters view failures as “nothing more than a statistic” rather than identity-threatening events. The sport teaches that “failure is the most consistent part of lifting.” This normalizes setbacks as information for improvement rather than defeats.

Hormesis effect - small stress doses strengthen the system. People experiencing varied, manageable stressors develop “redundancy in coping” - multiple strategies preventing single points of failure.

Antifragility needs skin in the game though. Personal investment ensuring learning from outcomes. In powerlifting, there’s no hiding from results. The bar either goes up or it doesn’t. This creates accountability that forces genuine adaptation.

resilience isn’t a personality trait

Resilience emerges from interaction of protective factors: individual attributes (cognitive ability, self-regulation), relationships (caring connections, high expectations), community resources (effective institutions, prosocial networks).

Most common response to adversity (35-65% of people) is “minimal-impact resilience” - maintaining stable functioning despite challenges. Not extraordinary characteristics but flexible coping strategies adapted to context. “Coping flexibility” - matching strategies to situational demands.

Avoiding personalization, pervasiveness, and permanence in explanations of negative events maintains psychological resources for growth. Positive emotions during stress create upward spirals.

the dose makes the poison (and powerlifters understand dosage)

HPA axis shows rapid habituation to identical stressors, cortisol responses decreasing over time. 47 studies show moderate to large effect sizes for habituation capacity.

Elite powerlifters show 40-60% cortisol elevations during competition - massive stress responses that would impair function in untrained individuals. Yet they maintain peak performance because they’ve adapted their stress systems through systematic exposure.

Psychological challenge follows inverted-u curve though. Insufficient challenge = no growth. Excessive challenge = harm. This is why periodization works - accumulation phases build volume tolerance, intensification phases develop comfort with heavy loads, realization phases peak psychological readiness.

Progressive overload applies to psychological strength like physical training. Start at 60-70% max capacity, increase 10-15% for optimal adaptation without overwhelming systems. Good powerlifting programs naturally implement this through percentage-based training.

Active engagement is crucial. You can’t just passively experience stress - you need deliberate practice of coping strategies. In powerlifting, this means visualization, breathing techniques, self-talk patterns, arousal control. The Barnicle & Lepage study showed sport psych interventions produced 6% performance improvements in elite powerlifters.

Recovery periods essential. Understanding that training stress, work stress, and life stress all contribute to total fatigue load. Modern powerlifters track HRV, prioritize sleep, manage nutrition timing. You get better by training, but gains are made when you rest.

This represents a shift from viewing stress as harmful to understanding it as potentially beneficial when structured right. Our brains are remarkably plastic, capable of developing enhanced stress management through experience.

Twenty years of lifting has taught me something most people miss - the iron is just a tool for building psychological capacity. The real weight being moved is mental. Each rep under the bar is practice for handling life’s inevitable pressures.

Research comparing powerlifters to other athletes shows distinct psychological advantages. Superior goal achievement psychology. Greater self-reliance and personal accountability. Better tolerance for training failure and more adaptive responses to setbacks.

The key isn’t avoiding challenges but engaging with them to promote growth rather than damage. Graduated challenges with adequate support and recovery create antifragile humans. Whether you’re adding 5 pounds to your deadlift or tackling a new project at work, the principles remain the same.

We’re designed to grow stronger through struggle, more capable through challenge, more resilient through adversity… when those challenges are calibrated right. The barbell just happens to be one of the best teachers for this lesson.

breaking down 'are your lights on?' chapter by chapter

part 1: what is the problem?

chapter 1: a problem

the famous brontosaurus tower story kicks things off. 73-story building, elevators are terrible, tenants threatening to leave. everyone immediately jumps to solutions: faster elevators, more elevators, outside shafts, whatever.

but the authors stop and ask… whose problem is this actually??

if you think it’s the tenants’ problem, you get solutions like “speed up elevators.” if you think it’s the landlord’s problem, you get totally different solutions like “raise rents so you need fewer tenants” or my personal favorite, “burn down the building for insurance.”

the key insight: the fledgling problem solver invariably rushes in with solutions before taking time to define the problem being solved.

we’re all guilty of this. someone says “the website is slow” and we immediately start optimizing database queries instead of asking… slow for who? slow compared to what? is slow actually the problem or just a symptom?

chapter 2: peter pigeonhole prepares a petition

peter the mailboy organizes a petition about the elevators. landlord ignores it. workers escalate - they show up at his house with picket signs and stink bombs. suddenly his wife gets involved and now it’s definitely his problem too.

this introduces the “walking in moccasins” principle. problems don’t get solved until the pain is shared. when one party feels pain in sync with another, that’s when resolution happens.

also learned: if it’s their problem, make it their problem. peter gets assigned to solve the elevator issue because he started the whole thing. taking initiative means it becomes your problem.

chapter 3: what’s your problem?

here’s where they define what a problem actually is: a problem is a difference between things as desired and things as perceived.

if you want the room at 72 degrees but it feels like 68, you have a problem. doesn’t matter if the thermometer says 77 - if you perceive it as cold, the problem is real.

peter learns this and realizes elevator problems could be solved by changing perceptions instead of reality. so they install mirrors by the elevators. people get distracted checking themselves out and stop noticing the wait.

then vandals cover the mirrors with graffiti. peter’s solution? give everyone crayons to make their own graffiti while waiting.

plot twist: during annual inspection, they find a dead rat jammed in the master relay. the elevators were broken the whole time, not slow by design. when they fix it, elevators work perfectly… but now everyone rushes to the subway at once and the landlord gets trampled to death.

phantom problems are real problems - even if the root cause isn’t what you think, the experience of the problem is genuine and needs addressing.

part 2: what is the problem?

chapter 4: billy brighteyes bests the bidders

billy is a programmer who gets hired to solve a bidding problem. company has sealed bids from competitors and wants to compute 4 million possible combinations to find the optimal strategy.

billy looks at the rules for 5 minutes and solves it instantly with basic logic. the executives don’t believe him because if you solve their problem too readily, they’ll never believe you’ve solved their real problem.

also: don’t take their solution method for a problem definition. just because they want 4 million calculations doesn’t mean that’s actually the problem.

chapter 5: billy bites his tongue

plot twist - billy later discovers the same problem was solved by another company using linear programming packages. cost them $1,400 and took 3 days. his 5-minute solution saved way more money but wasn’t trusted.

lesson: don’t mistake a solution method for a problem definition, especially if it’s your own solution method.

sometimes people want the comfort of a complex, expensive solution because it feels more legitimate than a simple one.

chapter 6: billy back to the bidders

billy realizes the whole bidding situation was probably rigged from the start. if everyone bought the “secret” bids and changed theirs accordingly, then the original bids were just meant to mislead competitors.

this leads to infinite recursion: if i know you know i know you know… it becomes impossible to determine what’s real.

you can never be sure you have a correct definition, even after the problem is solved.

but that’s okay. the real lesson is: don’t leap to conclusions, but don’t ignore your first impression.

part 3: what is the problem, really?

chapter 7: the endless chain

dan the engineer creates a tool to mark precise measurements on paper - aluminum bar with pins that punch holes exactly 8 inches apart. works great until someone sets it down pin-side up and the section chief sits on it, getting two precise holes punched in his rear end.

each solution is the source of the next problem.

they “solve” this by grinding the legs into semicircles so it can’t stand upright. but this probably creates new problems too.

if you can’t think of at least three things that might be wrong with your understanding of the problem, you don’t understand the problem.

chapter 8: missing the misfit

designers create solutions for people they never meet, leading to “misfits” - solutions that don’t match actual human needs.

example: disposable razor blades were great for men who cut themselves sharpening razors, but terrible for wives and maids who cut themselves disposing of the blades. eventually someone invented packages that could hold used blades, but it took years to recognize the disposal problem.

each new point of view will produce a new misfit.

solution: test your definition on a foreigner, someone blind, or a child, or make yourself foreign, blind, or childlike.

chapter 9: landing on the level

shows a circle and asks “what is it?” most people say “circle” immediately. but change the problem statement to “this shows a very unfamiliar object” and suddenly people get creative: “hole,” “hula hoop,” “helicopter landing pad.”

the lesson is about “semantic levels” - we unconsciously decide what type of problem we’re dealing with, which constrains our solutions.

as you wander along the weary path of problem definition, check back home once in a while to see if you haven’t lost your way.

chapter 10: mind your meaning

“nothing is too good for our customers” - does this mean customers deserve the best, or that giving them nothing would be too generous?

words are tricky. the book gives examples of $500k mistakes caused by misplacing “too” in a sentence.

once you have a problem statement in words, play with the words until the statement is in everyone’s head.

they provide a “golden list” of word games: change positives to negatives, change “may” to “must,” replace “and” with “or,” draw pictures of sentences, express words as equations.

part 4: whose problem is it?

chapter 11: smoke gets in your eyes

classroom with 11 non-smokers and 1 cigar smoker. instead of the teacher mandating a solution, they let the students work it out. the smoker voluntarily agrees to stop smoking if everyone brings interesting snacks to share instead.

don’t solve other people’s problems when they can solve them perfectly well themselves.

if it’s their problem, make it their problem.

chapter 12: the campus that was all spaced out

university parking problem. students start parking in the president’s space, he threatens expulsion, they escalate to slashing his tires. meanwhile some faculty take a different approach: try blaming yourself for a change, even for a moment.

instead of “there aren’t enough parking spaces,” they reframe it as “i’m too lazy to walk far” or “i need exercise anyway.” some professors start deliberately parking in the farthest spots and walking for exercise.

if a person is in a position to do something about a problem, but doesn’t have the problem, then do something so he does.

chapter 13: the lights at the end of the tunnel

new tunnel has a sign: “turn your headlights on.” but at the scenic overlook after the tunnel, tourists leave lights on and drain batteries.

engineer considers complex solutions, then just puts up a simple sign at tunnel exit: “are your lights on?”

if people really have their lights on, a little reminder may be more effective than your complicated solution.

this is where the book title comes from.

part 5: where does it come from?

chapter 14: janet jaworski joggles a jerk

janet travels to poland to visit her grandmother. gray bureaucrat claims she’s missing one notarized copy of her paperwork. instead of assuming “bureaucracy is evil,” she asks: where does this problem come from?

could be: attendant lost the copy, she never had it, bureaucrat is incompetent, bureaucrat has different goals, bureaucrat lacks authority to make exceptions.

by not attributing it to “nature” or “that’s just how things are,” she keeps the problem solvable.

chapter 15: mister matczyszyn mends the matter

janet stops treating the bureaucrat like “mr. grayface” and learns his name is jan matczyszyn. turns out he’s human, they bond over family history, and he helps her make a copy for the missing paperwork.

the source of the problem is most often within you.

brutal but true. how we approach people affects how they respond.

chapter 16: make-works and take-credits

some people create work for others, some people do actual work. the book gives an example of a ridiculous memo about comma usage in punctuation reports.

in the valley of the problem solvers, the problem creator is king.

sometimes the “problem” exists just to justify someone’s job. solution: route the memo back with “fascinating concept, let’s discuss” written on it and watch the originator tie themselves in knots.

chapter 17: examinations and other puzzles

students learn to game homework by knowing which week’s material will be covered, then get destroyed on comprehensive finals. but exam problems aren’t from storks - they come from professors with biases and limitations.

who sent this problem? what’s he trying to do to me?

understanding the source helps solve the problem. multiple choice tests can often be solved by analyzing answer patterns rather than doing the math.

part 6: do we really want to solve it?

chapter 18: tom tireless tinkers with toys

young programmer finds a toy company with shipping cost optimization problem. he discovers they could save tons of money by closing two factories and making everything at the most efficient one.

“that’s true, but we can’t accept that solution.”

“why not?”

“because the president lives near the atlantic plant and the chairman lives in kansas city. they wouldn’t move for anything.”

in spite of appearances, people seldom know what they want until you give them what they ask for.

chapter 19: patience plays politics

programmer builds tax assessment system, treasurer complains the total is off by one penny due to rounding. she offers to donate a dollar to the state to cover rounding errors for the next decade.

not too many people, in the final analysis, really want their problems solved.

some problems are more useful unsolved because they justify someone’s existence or provide an excuse for inaction.

chapter 20: a priority assignment

code breaker spends two years cracking diplomatic codes, only to discover they’re just expense accounts. “twenty-three bottles scotch, fifty-nine wine…”

do i really want a solution?

sometimes the solution is so trivial it makes all your effort feel worthless. sometimes solving the problem eliminates your job or reveals uncomfortable truths.

the book doesn’t end with a neat conclusion because that would go against its entire philosophy. instead it just reminds you that problem solving is never morally neutral, and to “thine own self be true.”

the whole point is that there are no perfect problem definitions, no final solutions, and no universal frameworks. just keep asking the right questions and stay suspicious of your own assumptions.

how many times have i built exactly what the client asked for, only to have them realize it wasn’t what they needed? how many features have i added that created more problems than they solved?

sometimes the best solution is to do nothing at all. the elevator in brontosaurus tower worked fine once they removed the dead rat. sometimes the problem really is just a dead rat in the machine, and all the sophisticated solutions in the world won’t help until you deal with that basic fact.

Effect's pipe: the backbone of composable TypeScript

You know that feeling when you chain Promises and suddenly you’re drowning in nested try-catch blocks? Effect’s pipe function takes a radically different approach. It’s not just another functional programming utility—it’s a complete rethinking of how we compose operations in TypeScript.

The pipe that knows what can go wrong

Here’s what makes Effect’s pipe different right off the bat:

const result = pipe(
  Effect.succeed(42),
  Effect.map(n => n / 2),
  Effect.flatMap(n => n > 20 ? Effect.succeed(n) : Effect.fail("too small")),
  Effect.mapError(msg => new ValidationError(msg))
)
// Type: Effect<number, ValidationError, never>

See that type signature? Every possible error is tracked. Every dependency is explicit. This isn’t your typical pipe function that just threads values through transformations. Effect’s pipe carries three pieces of information through every step: the success value, possible errors, and required dependencies.

How Effect pulls off this magic

The implementation relies on TypeScript’s overloading system pushed to its limits. Effect provides 20+ overloads to maintain type safety through long pipelines:

pipe<A, B, C, D>(
  a: A, 
  ab: (a: A) => B, 
  bc: (b: B) => C, 
  cd: (c: C) => D
): D

But here’s where it gets interesting. Effect uses a “dual API” pattern. Every function works two ways:

import { dual, pipe } from "effect/Function"

const sum = dual<
  (that: number) => (self: number) => number,
  (self: number, that: number) => number
>(2, (self, that) => self + that)

// Both styles work
sum(2, 3)        // Data-first: 5
pipe(2, sum(3))  // Data-last: 5

This isn’t just syntactic sugar. It’s about flexibility. Sometimes you want method chaining. Sometimes you want function composition. Effect says: why choose?

Real-world patterns

Here’s a common pattern that works well:

const createInvitation = ({ email, role, userId, orgId }) =>
  pipe(
    validateInvitationData({ email, role }),
    Effect.andThen(() => checkUserPermissions({ userId, orgId })),
    Effect.andThen(() => generateInvitationId()),
    Effect.flatMap(invitationId => 
      saveInvitation({ invitationId, email, role, orgId })
    ),
    Effect.tap(() => sendInvitationEmail(email)),
    Effect.mapError(error => 
      error instanceof DatabaseError 
        ? new InternalServerError("Database operation failed")
        : error
    ),
    Effect.retry({ times: 3, schedule: Schedule.exponential(1000) }),
    Effect.timeout(10000)
  )

Notice how each concern is separate? Validation, authorization, business logic, error handling, retries, timeouts—all composed through pipe. In Promise-land, this would be a tangled mess of try-catch blocks and manual retry logic.

Performance: the elephant in the room

Let’s address it head-on. Effect has overhead. The fiber-based runtime adds ~15KB to your bundle (compressed). Initial execution is slower than raw Promises.

But here’s the thing: Effect shines when complexity grows. That weather app fetching data from three APIs? Effect’s concurrent execution model offers advantages over Promise.all:

const weatherData = pipe(
  Effect.all({
    current: fetchCurrentWeather(city),
    forecast: fetchForecast(city),
    alerts: fetchWeatherAlerts(city)
  }, { concurrency: "unbounded" }),
  Effect.retry({ times: 3, schedule: Schedule.exponential(500) }),
  Effect.timeout(5000)
)

Each operation gets its own retry logic. One fails? Others continue. The fiber runtime handles this elegantly while Promises would require custom orchestration.

Comparing to other pipes

F#’s pipe operator |> provides readable left-to-right composition:

[1; 2; 3; 4; 5]
|> List.filter (fun x -> x % 2 = 0)
|> List.map (fun x -> x * x)
|> List.sum

Beautiful. Type-safe. But it’s just syntax. No error tracking, no async handling, no dependency injection.

Elixir also uses pipe operators for data transformation:

"hello world"
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")

Elixir’s strength is combining pipes with pattern matching in function definitions, letting you handle different data shapes elegantly. But like F#, when you need explicit error tracking at the type level, you’re managing it yourself.

Unix pipes? They’re the OG:

cat file.txt | grep "pattern" | sort | uniq -c

Text streams, process isolation, parallel execution. These concepts influenced many modern functional programming approaches, including Effect’s composable operations.

Advanced patterns worth stealing

The service layer pattern works well for organizing Effect code:

const userService = {
  create: (userData: UserData) =>
    pipe(
      Schema.decodeUnknown(UserSchema)(userData),
      Effect.andThen(checkEmailUniqueness),
      Effect.andThen(hashPassword),
      Effect.flatMap(saveToDatabase),
      Effect.tap(sendWelcomeEmail),
      Effect.catchTags({
        ParseError: () => Effect.fail(new ValidationError("Invalid user data")),
        DatabaseError: () => Effect.fail(new ServiceUnavailable())
      })
    )
}

Each method is a pipeline. Errors bubble up with proper types. Testing? Swap the database layer:

const testLayer = Layer.succeed(DatabaseService, {
  save: () => Effect.succeed({ id: "test-id" }),
  find: () => Effect.succeed(null)
})

const result = pipe(
  userService.create(userData),
  Effect.provide(testLayer),
  Effect.runPromise
)

The retry scheduler pattern prevents naive retry loops:

const smartRetry = pipe(
  Schedule.exponential(Duration.seconds(1)),
  Schedule.jittered,
  Schedule.whileOutput(Duration.lessThanOrEqualTo(Duration.minutes(1))),
  Schedule.tapOutput(duration =>
    Effect.log(`Retrying after ${duration.seconds} seconds`)
  )
)

Exponential backoff with jitter, maximum duration, and logging. Try implementing that with Promises.

When Effect’s pipe actually helps (and when it doesn’t)

Effect shines when you have:

  • Complex error scenarios that need explicit handling
  • Multiple async operations that might fail independently
  • Business logic requiring retries, timeouts, and fallbacks
  • Team members who keep shipping bugs because “we forgot to handle that error”

Skip Effect when you’re:

  • Building a simple CRUD app with predictable failures
  • Working with a team hostile to functional programming
  • Prototyping something you’ll throw away next week

The migration path that actually works

Teams succeeding with Effect don’t rewrite everything. They start at the boundaries:

// Old Promise-based code
async function fetchUserData(id: string): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) throw new Error('User not found')
    return await response.json()
  } catch {
    return null
  }
}

// Gradual Effect adoption
const fetchUserData = (id: string) =>
  pipe(
    Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: () => new NetworkError()
    }),
    Effect.filterOrFail(
      response => response.ok,
      () => new UserNotFoundError()
    ),
    Effect.andThen(response => 
      Effect.tryPromise({
        try: () => response.json(),
        catch: () => new ParseError()
      })
    ),
    Effect.andThen(Schema.decodeUnknown(UserSchema))
  )

Now errors are explicit. The compiler catches missing error handling. The team gradually learns Effect patterns without a big-bang rewrite.

The philosophical shift

Effect’s pipe isn’t just about chaining functions. It’s about making the implicit explicit. Every function in a pipeline must declare what it needs, what it returns, and what can go wrong.

Traditional error handling hides failure:

try {
  const user = await getUser(id)
  const profile = await getProfile(user.id)
  return await enrichProfile(profile)
} catch (error) {
  console.error('Something went wrong:', error)
  return null
}

What failed? Who knows. Effect forces honesty:

pipe(
  getUser(id),                    // Effect<User, UserNotFoundError, never>
  Effect.andThen(u => getProfile(u.id)), // Effect<Profile, ProfileError, never>
  Effect.andThen(enrichProfile),  // Effect<RichProfile, EnrichmentError, never>
  Effect.catchTags({
    UserNotFoundError: () => Effect.succeed(guestProfile),
    ProfileError: (e) => Effect.fail(new IncompleteDataError()),
    EnrichmentError: () => Effect.succeed(basicProfile)
  })
)

Every failure mode is visible. Every recovery strategy is explicit. The types tell the whole story.

Type inference that doesn’t make you cry

Effect preserves types through insanely long pipelines:

const complexPipeline = pipe(
  Effect.succeed({ name: "Alice", age: 30 }),
  Effect.map(user => ({ ...user, id: generateId() })),
  Effect.flatMap(user => 
    user.age >= 18 
      ? Effect.succeed(user)
      : Effect.fail(new UnderageError())
  ),
  Effect.andThen(user => fetchPremiumStatus(user.id)),
  Effect.map(status => status.isPremium ? "premium" : "basic"),
  Effect.retry({ times: 3 }),
  Effect.timeout(5000)
)
// Type: Effect<"premium" | "basic", UnderageError | FetchError | TimeoutException, never>

Twenty transformations deep? Types still flow. Compare this to Promise chains where you’re adding type annotations every other line just to keep TypeScript happy.

The ecosystem bonus

When you buy into Effect’s pipe, you get an entire ecosystem designed around it:

// HTTP client with built-in Effect support
const userData = pipe(
  HttpClientRequest.get("/api/user"),
  HttpClient.execute,
  Effect.andThen(response => response.json),
  Effect.andThen(Schema.decodeUnknown(UserSchema)),
  Effect.retry({ times: 3 }),
  Effect.provide(FetchHttpClient.layer)
)

// Stream processing 
const processedStream = pipe(
  Stream.fromIterable(largeDataset),
  Stream.map(processItem),
  Stream.filter(isValid),
  Stream.groupByKey(item => item.category),
  Stream.runCollect
)

Everything speaks the same language. Everything composes the same way.

Performance tricks

Memoization for expensive operations:

const expensiveCalculation = pipe(
  Effect.sync(() => {
    console.log("This only runs once!")
    return heavyComputation()
  }),
  Effect.cached
)

// Multiple calls, single execution
Effect.all([
  expensiveCalculation,
  expensiveCalculation,
  expensiveCalculation
])

Batching for database operations:

const getUserById = (id: string) =>
  pipe(
    Effect.request(GetUserById(id)),
    Effect.flatMap(Schema.decodeUnknown(UserSchema))
  )

// Automatically batches multiple getUserById calls
const users = Effect.all([
  getUserById("1"),
  getUserById("2"),
  getUserById("3")
])

The gotchas nobody talks about

Effect.gen looks tempting for developers coming from async/await:

const program = Effect.gen(function* () {
  const user = yield* getUser(id)
  const profile = yield* getProfile(user.id)
  return yield* enrichProfile(profile)
})

But overusing generators defeats the purpose. You lose the composability that makes pipe powerful. Save generators for complex control flow, not simple sequences.

Another gotcha: nested pipes get ugly fast:

// Don't do this
pipe(
  data,
  x => pipe(
    x,
    transform1,
    y => pipe(
      y,
      transform2,
      z => pipe(z, transform3)
    )
  )
)

Flatten it out. Extract functions. Keep pipes linear.

Effect’s pipe function represents a philosophical shift: stop pretending errors don’t exist. Stop hiding dependencies. Make everything explicit, and let the compiler help you build robust systems. It’s not always easy, but for complex applications, it’s a compelling choice for many complex applications.

Creating a Retro CSS Glitch Text Effect

Remember those corrupted VHS tapes where the image would tear and shift with weird colors? Here’s how to recreate that effect with CSS.

See It In Action

GLITCH EFFECT!

The Trick

Start with this HTML:

<h1 class="glitch" data-text="GLITCH EFFECT!">GLITCH EFFECT!</h1>

That data-text attribute? We’ll use it to create ghost copies of the text. First, give your text that classic red/blue shift from old CRT monitors:

.glitch {
  font-size: 2.5rem;
  font-weight: bold;
  color: #fff;
  text-shadow: 
    2px 2px 0 #ff00ff,    /* Magenta shadow */
    -2px -2px 0 #00ffff;  /* Cyan shadow */
  position: relative;
  animation: glitch 2s infinite;
}

Now here’s where it gets fun. Use pseudo-elements to stack two more copies of the text:

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
}

.glitch::before {
  animation: glitch-1 0.5s infinite;
  color: #ff00ff;
  z-index: -1;
}

.glitch::after {
  animation: glitch-2 0.5s infinite;
  color: #00ffff;
  z-index: -2;
}

The content: attr(data-text) is doing the heavy lifting here. It grabs the text from that data attribute we set up. Now you’ve got three layers of text ready to glitch out.

For the main text, animate those shadow positions to create movement:

@keyframes glitch {
  0%, 100% { text-shadow: 2px 2px 0 #ff00ff, -2px -2px 0 #00ffff; }
  25% { text-shadow: -2px 2px 0 #ff00ff, 2px -2px 0 #00ffff; }
  50% { text-shadow: 2px -2px 0 #ff00ff, -2px 2px 0 #00ffff; }
  75% { text-shadow: -2px -2px 0 #ff00ff, 2px 2px 0 #00ffff; }
}

But the real magic happens with clip-path. This is what creates that digital tearing effect:

@keyframes glitch-1 {
  0%, 100% { 
    clip-path: inset(0 0 0 0); 
    transform: translate(0); 
  }
  20% { 
    clip-path: inset(0 0 50% 0);
    transform: translate(-5px);
  }
  40% { 
    clip-path: inset(50% 0 0 0);
    transform: translate(5px);
  }
  60% { 
    clip-path: inset(0 0 0 0); 
    transform: translate(0); 
  }
}

clip-path: inset() chops off parts of the text. inset(0 0 50% 0) hides the bottom half, inset(50% 0 0 0) hides the top. Combine that with some horizontal shifts and you get that corrupted video look.

Making It Your Own

Want a subtle glitch? Slow down the animations. Want chaos? Speed them up to 0.2s. The sweet spot is usually around 0.5s for the pseudo-elements.

Different color combos work great too. Try green-on-green for that terminal look, or go full cyberpunk with purple and yellow. The key is high contrast. You want those layers to pop.

If you really want to sell the effect, add some JavaScript to trigger random intense glitches:

setInterval(() => {
  if (Math.random() > 0.7) {
    glitchElement.style.animation = 'glitch 0.2s infinite';
    setTimeout(() => {
      glitchElement.style.animation = 'glitch 2s infinite';
    }, 200 + Math.random() * 300);
  }
}, 3000);

Quick Notes

clip-path works everywhere that matters. If you’re worried about performance, stick to one or two glitch elements per page. Three layers of animated text can get heavy.

Oh, and for accessibility, kill the animations for people who prefer reduced motion:

@media (prefers-reduced-motion: reduce) {
  .glitch,
  .glitch::before,
  .glitch::after {
    animation: none !important;
  }
}

That’s it. Pure CSS, no libraries, just some clever use of pseudo-elements and animations. Perfect for headers, loading screens, or anywhere you want that corrupted digital aesthetic.

Print Debugging

The Paradox of Choice was popularized by psychologist Barry Schwartz in his 2004 book of the same name, though the underlying concept has roots in earlier psychological and economic research.

Schwartz argued against the conventional wisdom that more options always lead to better outcomes and greater satisfaction. Instead, he demonstrated through various studies that beyond a certain point, additional choices can lead to decision paralysis, increased anxiety, and decreased satisfaction with whatever choice is ultimately made. His famous example involved a study of jam purchases at a grocery store: when customers were presented with 24 varieties of jam, fewer people made purchases compared to when only 6 varieties were available, and those who did buy from the larger selection were less satisfied with their choice.

This principle perfectly explains why developers default to print debugging despite having superior tools available. When faced with a bug, modern IDEs offer an overwhelming array of sophisticated options: integrated debuggers with breakpoints, step-through execution, variable watches, call stack inspection, conditional breakpoints, and profiling tools. Each tool requires learning its interface, making decisions about where to set breakpoints, and understanding how to interpret the results. This cognitive overhead creates decision paralysis, so developers instinctively retreat to the simplest option that requires zero choices: scattering print statements throughout their code. It’s the debugging equivalent of ordering the same meal at a restaurant with a 50-page menu - the familiar option feels safer than navigating all the potentially better alternatives, even when you know those alternatives would ultimately serve you better.

Debugger interface showing variable inspection

Every developer has reached for console.log or other similiar print statements when code misbehaves. You sprinkle logs throughout your functions, refresh the browser, and squint at the console trying to piece together what went wrong. While this approach feels familiar, it’s one of the least efficient ways to debug code, especially when tracing complex program flow or call stacks.

At its core, console.log debugging is just an inefficient, stone-age version of watch variables. You’re essentially trying to manually recreate what modern debuggers do automatically - except you have to guess what to watch, modify your source code, and parse through cluttered output instead of seeing clean, real-time variable updates.

The Three Core Problems with Print Debugging

First, you’re debugging blind. When tracking down bugs in deep call stacks, you’re playing a guessing game about where to place diagnostic output. You don’t know the actual execution path, so you scatter logs based on hunches. Consider this recursive function that’s causing stack overflows:

// Initial version - just crashes somewhere
function processNestedData(data, depth = 0) {
    if (!data) return null;

    if (data.children) {
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    return data.value;
}

// First debugging attempt - add some logs
function processNestedData(data, depth = 0) {
    console.log('processNestedData called with:', data, 'depth:', depth);

    if (!data) {
        console.log('data is null/undefined, returning');
        return null;
    }

    console.log('about to check children');
    if (data.children) {
        console.log('has children, recursing');
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    console.log('returning value:', data.value);
    return data.value;
}

// Still crashing, need more detail
function processNestedData(data, depth = 0) {
    console.log(`[DEPTH ${depth}] Processing:`, JSON.stringify(data));

    if (!data) return null;

    console.log(`[DEPTH ${depth}] Data type:`, typeof data);
    console.log(`[DEPTH ${depth}] Has children:`, !!data.children);

    if (data.children) {
        console.log(`[DEPTH ${depth}] Children count:`, data.children.length);
        console.log(`[DEPTH ${depth}] About to map over children`);

        const result = data.children.map((child, index) => {
            console.log(`[DEPTH ${depth}] Processing child ${index}:`, child);
            return processNestedData(child, depth + 1);
        });

        console.log(`[DEPTH ${depth}] Finished processing children`);
        return result;
    }

    return data.value;
}

You’re stuck in an endless cycle of adding logs, refreshing, running, and still not knowing exactly where the circular reference is or when the infinite recursion starts. Console.log forces you to predict what variables matter, while debugger watch variables let you inspect anything on demand.

Second, the development cycle kills productivity. Each investigation requires modifying source code, rebuilding (if using a bundler), and reloading. Modern build tools might be fast, but you’re still waiting to see if your latest batch of console.log statements provides useful insight. When they don’t—which happens frequently—you’re back to modifying code and waiting for another reload cycle. Look at how this simple bug hunt spirals out of control:

// First attempt - add some logs
async function fetchUserProfile(userId) {
    console.log('Fetching profile for user:', userId);  // Round 1

    const response = await fetch(`/api/users/${userId}`);
    const userData = await response.json();

    if (validateUserData(userData)) {
        updateUserCache(userData);
        renderUserProfile(userData);
    }
}

// Still not clear what's wrong, add more logs
async function fetchUserProfile(userId) {
    console.log('Fetching profile for user:', userId);
    console.log('User ID type:', typeof userId, 'Value:', userId);  // Round 2

    const response = await fetch(`/api/users/${userId}`);
    console.log('Response status:', response.status);  // Round 2
    console.log('Response headers:', response.headers);  // Round 2

    const userData = await response.json();
    console.log('Parsed user data:', userData);  // Round 2

    if (validateUserData(userData)) {
        console.log('Validation passed');  // Round 2
        updateUserCache(userData);
        renderUserProfile(userData);
    } else {
        console.log('Validation failed');  // Round 2
    }
}

// Still need to dig deeper, modify validation function
function validateUserData(userData) {
    console.log('Validating user data:', userData);  // Round 3
    console.log('Has email:', !!userData.email);  // Round 3
    console.log('Email value:', userData.email);  // Round 3

    if (!userData.email || userData.email.length === 0) {
        console.log('Email validation failed');  // Round 3
        return false;
    }
    // ... more validation logic with more logs
}

This is the stone age approach to variable inspection. You’re manually recreating what watch variables do automatically, but with all the overhead of code modification and none of the flexibility.

Third, you lose the bigger picture. Console.log debugging only shows you the specific points you thought to instrument, potentially reinforcing incorrect assumptions about how your code behaves. It’s like trying to watch a movie by taking random screenshots - you miss the flow and context. Consider this Promise chain where the bug could be anywhere:

function handleUserAction(actionType, payload) {
    console.log('Handling action:', actionType, payload);

    switch(actionType) {
        case 'LOGIN':
            return authenticateUser(payload)
                .then(result => {
                    console.log('Auth result:', result);  // Is this even called?
                    return updateUserSession(result);
                })
                .then(session => {
                    console.log('Session updated:', session);  // What about this?
                    return redirectToProfile(session.userId);
                });
        case 'LOGOUT':
            return clearUserSession()
                .then(() => {
                    console.log('Session cleared');  // Silent failure?
                    return redirectToHome();
                });
    }
}

async function authenticateUser(credentials) {
    console.log('Authenticating with:', credentials.username);
    // Where do I put the next log? In the API call? In error handling?
    try {
        const response = await fetch('/api/auth', {
            method: 'POST',
            body: JSON.stringify(credentials)
        });
        return await response.json();
    } catch (error) {
        console.log('Auth error:', error);  // But what kind of error?
        throw error;
    }
}

You’re instrumenting based on assumptions, potentially missing the real problem entirely.

The Three Advantages of Interactive Debugging

Start high and step down systematically. With a debugger, you set one breakpoint and step through the actual execution path. Watch variables show you real-time updates without any code modification:

function processNestedData(data, depth = 0) {
    if (!data) return null;  // <- Breakpoint here, add 'data' to watch

    if (data.children) {     // <- Step through, watch 'data.children' update
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    return data.value;
}

In your browser debugger watch panel, you immediately see the problem:

▼ data: Object
  ▼ children: Array(1)
    ▼ 0: Object
        value: "child"
      ▼ parent: Object [Circular *1]  <- Circular reference detected!

No code modification needed. No parsing through console spam. Just clean, structured variable inspection.

Adapt your investigation in real time. Instead of guessing what to log, you examine variables on demand and add new watches instantly:

async function fetchUserProfile(userId) {
    // Breakpoint here - add userId to watch, see it's undefined
    const response = await fetch(`/api/users/${userId}`);  // Add response to watch
    const userData = await response.json();  // Add userData to watch

    if (validateUserData(userData)) {  // Step into this, watch validation logic
        updateUserCache(userData);     // Watch cache updates in real-time
        renderUserProfile(userData);
    }
}

In the debugger watch window (updated in real-time as you step):

userId: undefined        <- Added to watch when you noticed the problem
response.status: 404     <- Added when you stepped to this line
userData: {error: "User not found"}  <- Added automatically as you stepped
validateUserData(userData): false    <- Added to watch the function result

This is like having a time machine for your variables - you can watch how they change over time without having to predict what you’ll need to see.

See the complete context. For complex async flows, you can trace the entire chain while watching variables update across the entire call stack:

function handleUserAction(actionType, payload) {
    // Breakpoint here, step through the switch
    // Watch panel shows: actionType: "LOGIN", payload: {username: "john"}
    switch(actionType) {
        case 'LOGIN':
            return authenticateUser(payload)  // Step into, watch variables flow down
                .then(result => updateUserSession(result))  // Watch result propagate
                .then(session => redirectToProfile(session.userId));
    }
}

async function authenticateUser(credentials) {
    // Watch panel now shows: credentials: {username: "john", password: "***"}
    const response = await fetch('/api/auth', {
        method: 'POST',
        body: JSON.stringify(credentials)
    });
    // Watch panel updates: response: Response {status: 200, ok: true, ...}
    return await response.json();
}

Your debugger shows the complete call stack and async flow, with variables updating in real-time across all scopes.

The transition from print debugging requires some initial learning investment, but the productivity gains are substantial.

Modern browsers make this easier than ever, with powerful debugging tools built right into DevTools. The time spent learning these tools pays dividends in every debugging session thereafter, turning what used to be a time-consuming guessing game into a systematic process of investigation and understanding.