Stephen Awuah

when Koyeb (running Heroku buildpacks) ghosted me: a TypeScript compiler meltdown story

How I learned that Koyeb uses Heroku under the hood, and both decided to hate my code simultaneously

Febuary 6, 2026 • Stephen Awuah • 18 min read

The Incident: A Plot Twist I Didn't See Coming

It's 2 AM on a Tuesday. I'm shipping a beautiful new map routing feature for our real estate app. You know, the kind of feature that makes product managers cry tears of joy—property seekers can now calculate routes from their location to listings. Chef's kiss.

I push to Koyeb (because I'm fancy and like their UI):

git push origin main

Koyeb's build logs start scrolling. Then I see something... familiar:

* Running scripts
  * Running `npm run build`
    > flexdown@0.0.2 build
    > tsc
    
src/modules/map/map.controller.ts(32,13): error TS1434: Unexpected keyword or identifier.
src/modules/map/map.controller.ts(32,22): error TS1005: ';' expected.
src/modules/map/map.controller.ts(32,27): error TS1434: Unexpected keyword or identifier.

! Failed to execute build script
!
! The Heroku Node.js npm Install buildpack allows customization...
!
! If the issue persists and you think you found a bug in the buildpack,
! reproduce the issue locally with a minimal example. Open an issue in 
! the buildpack's GitHub repository and include the details here:
! __https://github.com/heroku/buildpacks-nodejs/issues__

ERROR: failed to build: exit status 1
Build failed ❌

Wait. WAIT.

"The Heroku Node.js npm Install buildpack"???

I'm on Koyeb, not Heroku. Why is Heroku mentioned in my Koyeb logs?

Googles frantically

Oh. OH.

Koyeb uses Heroku buildpacks under the hood. They're literally running the same build system.

So when I thought I was using a "modern alternative to Heroku," I was actually just using... Heroku with extra steps and a prettier dashboard.

This is like ordering an off-brand soda and finding out it's made in the same factory as Coca-Cola.

Meanwhile, on my Mac:

$ npm run build
✅ Build successful

The Realization: I'm not fighting Koyeb. I'm fighting Heroku's buildpack, which Koyeb borrowed. And both my Mac and these platforms are Unix/LF based, so... what's the actual problem?


Initial Investigation: The "But We're All Unix!" Phase

The Setup

We're all in the Unix family. We should be getting along!

Hypothesis 1: Line Endings (Immediately Rejected)

First instinct: "Maybe CRLF vs LF?"

$ file src/modules/map/map.controller.ts
src/modules/map/map.controller.ts: UTF-8 Unicode text

LF confirmed. Not Windows CRLF nonsense.

$ git config core.autocrlf
input  # Correct for Unix systems

So... not line endings.

Status: Hypothesis dead on arrival.

Hypothesis 2: Heroku Buildpack Caching

I noticed this in the logs:

! If the issue persists and you think you found a bug in the buildpack...
! __https://github.com/heroku/buildpacks-nodejs/issues__

Wait, so Koyeb is using Heroku's buildpack, which means:

This isn't a Koyeb problem. This is a Heroku buildpack problem running on Koyeb infrastructure.

Hypothesis 3: TypeScript Version Mismatch (BINGO)

# My local environment
$ npx tsc --version
Version 5.3.3

# My package.json
$ cat package.json | grep typescript
"typescript": "^5.3.0"

That ^ symbol. That little caret of chaos.

What it means:

The Heroku buildpack caches dependencies aggressively. Once it builds your app with TypeScript 5.3.0, it keeps using that version until you explicitly clear the cache or update your lock file.

So while I'm living my best life with 5.3.3 locally, Koyeb (via Heroku buildpack) is stuck in the past with 5.3.0.


Root Cause Analysis: The Crime Scene at Line 32

Let me show you the code that made Heroku's buildpack (running on Koyeb) lose its mind:

if (!userLocation || !propertyLocation) {
  res.status(400).json({
    success: false,
    message: 'User location and property location are required'
  });
  return; 

  // add this just to push a new update
}

That comment? That's the villain.

What TypeScript 5.3.0 (Heroku Buildpack) Sees:

[Parser] return statement detected
[Parser] blank line detected  
[Parser] comment token detected
[Parser] closing brace detected

[Parser Brain]: 
  "Is this comment part of return?"
  "Is it standalone?"  
  "Is it attached to the next statement?"
  "SYNTAX ERROR: EXISTENTIAL CRISIS MODE"

ERROR TS1434: Unexpected keyword or identifier

The parser literally cannot decide what to do with a comment that has whitespace before it and a closing brace after it.

What TypeScript 5.3.3 (My Mac) Sees:

[Parser] return statement
[Parser] whitespace (whatever, it's fine)
[Parser] comment token (cool, comments are legal)
[Parser] closing brace

[Parser Brain]: "Everything's fine, moving on."

BUILD SUCCESSFUL ✅

The difference: Between 5.3.0 and 5.3.3, TypeScript improved its whitespace handling around comments in control flow contexts. My local version is chill. The buildpack's cached version is NOT.

Why This Is a Heroku Buildpack Issue

The error message literally tells us:

! The Heroku Node.js npm Install buildpack allows customization...
!
! If the issue persists and you think you found a bug in the buildpack...

Koyeb is using Heroku's buildpack, which means:

  1. Aggressive caching: Once it installs ^5.3.0 and gets 5.3.0, it caches that FOREVER
  2. Semver resolution: Respects package-lock.json, but if that's not committed or gets regenerated, it picks whatever version is available at build time
  3. Build reproducibility: Supposed to be deterministic, but only if your lock file is committed

Technical Deep Dive: Unix, LF, and Why None of That Matters Here

The Red Herring: Line Endings

People (including me at 2 AM) love to blame line endings:

But here's the thing: This issue has nothing to do with line endings.

Both macOS and Linux use LF. The Heroku buildpack runs on Linux (LF). My code has LF. Everyone's using LF.

The TypeScript parser doesn't care about the literal bytes \r\n vs \n. It cares about the Abstract Syntax Tree (AST) structure.

What Actually Matters: Parser State Machine

TypeScript's parser is a state machine that goes:

Source Code
    ↓
Scanner/Lexer (tokenizes: keywords, symbols, whitespace, comments)
    ↓
Parser (builds AST) ← WE DIE HERE
    ↓
Binder (creates symbol tables)
    ↓
Type Checker (validates types)
    ↓
Emitter (outputs JavaScript)

My code dies in Phase 2: Parser trying to build the AST.

The parser sees:

return;    ← Statement node (complete)
           ← Whitespace (creates separation)
// comment ← Comment node (ambiguous association)
}          ← Block end (unexpected context)

TypeScript 5.3.0 parser logic:

if (previousNode === ReturnStatement && currentToken === Comment) {
  if (hasWhitespaceBetween && nextToken === CloseBrace) {
    // Unclear if comment belongs to return or block
    throw TS1434("Unexpected keyword or identifier");
  }
}

TypeScript 5.3.3 parser logic:

if (previousNode === ReturnStatement && currentToken === Comment) {
  // Comments can float between statements, it's fine
  parseCommentNode();
  continue;
}

This is a parser improvement, not a line ending issue.

Why Heroku Buildpack Caching Screwed Me

Heroku's buildpack (which Koyeb uses) caches aggressively:

# First build
- Detects package.json: "typescript": "^5.3.0"
- Resolves to TypeScript 5.3.0 (current at the time)
- Caches node_modules with TypeScript 5.3.0
- Creates layer cache

# Second build (my deploy)
- Detects package.json hasn't changed
- Uses cached node_modules (TypeScript 5.3.0)
- Never checks if 5.3.3 exists
- Builds with old version
- FAILS because parser doesn't like my comment

Meanwhile, my Mac:

$ npm install
- Resolves "^5.3.0"
- Finds TypeScript 5.3.3 (latest)
- Installs 5.3.3
- Builds successfully

The problem: Semver ranges + aggressive caching = version drift between local and production.


Resolution Strategy: Fixing the Heroku Buildpack Chaos

The Immediate Fix (Tactical)

Step 1: Delete the cursed comment

// BEFORE (breaks Heroku buildpack)
return; 

// add this just to push a new update

// AFTER (works everywhere)
return;

Why this works: Removes the parser ambiguity entirely.

Step 2: Deploy

git add .
git commit -m "fix: remove comment that TypeScript 5.3.0 hates"
git push origin main

Koyeb (via Heroku buildpack):

✅ Build succeeded
✅ Deployed

Victory! But we're not done.

The Long-term Fix (Strategic)

1. Pin TypeScript to Exact Version

// package.json - BEFORE
{
  "devDependencies": {
    "typescript": "^5.3.0"  // ❌ Allows 5.3.x updates
  }
}

// package.json - AFTER  
{
  "devDependencies": {
    "typescript": "5.3.3"   // ✅ Exact version only
  }
}

This tells the Heroku buildpack: "Install THIS EXACT VERSION, not whatever you feel like caching."

2. Lock Node.js and npm Versions

{
  "engines": {
    "node": "18.19.1",
    "npm": "10.2.4"
  }
}

The Heroku buildpack respects the engines field and will use these exact versions.

3. Regenerate and Commit Lock File

# Delete everything
rm -rf node_modules package-lock.json

# Fresh install with exact versions
npm install

# Commit the lock file
git add package.json package-lock.json
git commit -m "chore: pin TypeScript to 5.3.3"
git push origin main

Now package-lock.json has TypeScript 5.3.3 locked in. The Heroku buildpack will respect this.

4. Clear Koyeb's Build Cache

In Koyeb dashboard:

  1. Go to your service settings
  2. Find "Build Cache" or "Clear Cache"
  3. Click it
  4. Redeploy

Or, force a clean build:

# Add empty commit to trigger rebuild
git commit --allow-empty -m "chore: clear Koyeb cache"
git push origin main

5. Verify Versions Match

# Local
$ npx tsc --version
Version 5.3.3

# In Koyeb logs after deploy
[Building] Installing dependencies...
[Building] typescript@5.3.3 installed
[Building] Running npm run build...
✅ Build successful

The Senior Engineer Wisdom™ I Gained

1. Koyeb Uses Heroku Under the Hood (Mind = Blown)

When I saw this in the logs:

! The Heroku Node.js npm Install buildpack allows customization...

I realized: Koyeb isn't a Heroku alternative. It's a Heroku buildpack wrapper with a prettier UI.

This changes everything:

2. Semver Ranges + Caching = Pain

"typescript": "^5.3.0"  // ❌ "Surprise me with any 5.3.x version!"
"typescript": "5.3.3"   // ✅ "I know exactly what I want"

For build tools (TypeScript, Webpack, etc.), always pin exact versions.

For runtime dependencies, use ~ (patch updates only):

"express": "~4.18.0"  // Allows 4.18.x patches, not 4.19.0

3. Unix/LF Doesn't Guarantee Compatibility

All Unix systems use LF, but that doesn't mean code will build identically:

Environment parity requires version parity, not just OS parity.

4. Comments Are Not Harmless

A single comment with weird whitespace broke production.

Delete throwaway comments. Use Git for notes:

git commit --allow-empty -m "trigger rebuild for cache refresh"

5. Read Your Build Logs Carefully

That Heroku buildpack error message was THE clue that Koyeb uses Heroku under the hood. I almost missed it.


Epilogue: A Letter to Koyeb

Dear Koyeb,

I love your UI. I love your developer experience. I love that you're trying to be better than Heroku.

But... you're literally running Heroku buildpacks.

Could you maybe... mention this more prominently in the docs? Like, right at the top?

"⚠️ We use Heroku buildpacks, so Heroku best practices apply."

Would've saved me 4 hours at 2 AM.

Still love you though,
A Tired Developer

P.S. - Your dashboard is prettier than Heroku's. That counts for something.


TL;DR (For the Skimmers)

What happened:

The twist:

Why it broke:

The villain:

return;

// add this just to push a new update  ← This comment

The fix:

  1. Deleted the comment
  2. Pinned TypeScript: "typescript": "5.3.3"
  3. Pinned Node.js in engines field
  4. Committed package-lock.json
  5. Cleared Koyeb build cache

Key lessons:


The Meme That Sums It Up

Me: "I'll use Koyeb instead of Heroku"

Koyeb build logs: "Heroku Node.js buildpack..."

Me: "Wait—"

Koyeb: "Always has been 🔫"

Written at 5:47 AM after discovering Koyeb's secret. If you're debugging a similar issue, now you know: it's Heroku all the way down.

Using Koyeb and hit a weird build issue? Drop a comment. Let's compare Heroku buildpack war stories. 😅