Skip to main content
← Back to Blog
·Shipi18n Team

Why Placeholder Bugs Are the #1 Silent Failure in i18n Pipelines

Placeholder mismatches between translations cause runtime crashes that pass linting and tests. Here's why they're hard to catch and how to detect them.

i18ndebuggingengineeringbest-practices

Key takeaway

Placeholder mismatches between translations cause runtime crashes that pass linting and tests. Here's why they're hard to catch and how to detect them.

Your i18n pipeline is probably broken and you don't know it yet.

Not broken in a way that fails your CI. Not broken in a way that your linter catches. Broken in a way that ships to production and crashes when a user in Spain clicks checkout.

The culprit: placeholder drift.


The Real-World Problem

Placeholder mismatches happen when the variables in your source strings don't match the variables in your translations. This sounds obvious. It's not.

Here's how it actually happens:

Translators remove variables. A translator sees "Thanks {{name}}, your order {{orderId}} is complete" and translates it to "Gracias, su pedido está completo". They simplified the message. The translation is grammatically correct. It's also broken.

Translators rename placeholders. They translate {{name}} to {{nombre}} because it makes sense in their language context. Your interpolation engine doesn't care that it makes sense—it's looking for {{name}}.

Translators reorder placeholders. In some languages, the natural word order puts the order ID before the name. If your interpolation engine is positional (like %s in C-style formatting), reordering breaks everything.

Keys get out of sync. Your developers update the English source but forget to update all 12 translation files. Or they update 11 of them. The 12th one ships with stale placeholders.

This happens in every i18n framework: i18next, react-intl, vue-i18n, ICU MessageFormat. The problem isn't the framework. The problem is the handoff between code and translations.


A Concrete Failure Example

Here's a real scenario. You have a checkout confirmation message:

// en.json
{
  "checkout_success": "Thanks {{name}}, your order {{orderId}} is complete"
}

Your translator delivers the Spanish version:

// es.json
{
  "checkout_success": "Gracias {{nombre}}, su pedido está completo"
}

Three problems:

  1. {{name}} became {{nombre}}—interpolation will fail
  2. {{orderId}} is missing entirely
  3. The translation is grammatically perfect Spanish

Why this passes linting:

Your JSON is valid. No syntax errors. ESLint doesn't know that {{nombre}} should be {{name}}. TypeScript doesn't either—these are just strings.

Why tests don't catch it:

Your unit tests run in English. Your integration tests probably do too. Even if you test the Spanish locale, you're likely testing that the key exists, not that the interpolation works correctly with real data.

// This test passes
expect(t('checkout_success')).toBeDefined()

// This test also passes (no actual interpolation)
expect(t('checkout_success')).toContain('{{')

Where it explodes:

Production. A user in Mexico completes checkout. Your app tries to interpolate:

t('checkout_success', { name: 'Maria', orderId: '12345' })

Depending on your framework:

  • i18next (lenient mode): Returns "Gracias {{nombre}}, su pedido está completo" with literal {{nombre}} visible to the user
  • i18next (strict mode): Throws an error or returns the key
  • react-intl: Throws a runtime error if using ICU MessageFormat
  • Some frameworks: Silently return undefined for the missing values

None of these outcomes are good. All of them passed CI.


Why This Is Hard to Detect

Static Analysis Has Limits

Static analysis tools can parse your translation files. They can check for valid JSON or YAML. They can verify keys exist across locales. But comparing placeholder semantics requires understanding both:

  1. What placeholders exist in the source string
  2. What placeholders should exist in every translation

This is harder than it sounds. Consider:

// English (plural forms)
"items_count": "You have {{count}} item"
"items_count_plural": "You have {{count}} items"

// German (different plural rules)
"items_count_one": "Sie haben {{count}} Artikel"
"items_count_other": "Sie haben {{count}} Artikel"

Is this a mismatch? Depends on your pluralization setup. Static analysis needs to understand your i18n configuration to make this call.

Translators Aren't the Problem

It's tempting to blame translators, but they're doing their job: producing natural-sounding translations. They're not engineers. They shouldn't need to understand that {{orderId}} is a sacred token that must be preserved exactly.

Some translation management systems (TMS) highlight placeholders. Some lock them. But many don't, and even when they do, it's easy to accidentally delete or modify a placeholder while editing.

The responsibility for catching these errors belongs in your pipeline, not with your translators.

CI Pipelines Miss This

Typical CI checks for i18n:

  • ✅ JSON/YAML is valid
  • ✅ All keys exist in all locales
  • ✅ No duplicate keys
  • ❌ Placeholder consistency
  • ❌ Placeholder naming
  • ❌ Placeholder count

Most teams don't have tooling for the last three checks. They rely on QA or production monitoring to catch these issues. By then, users have already seen broken strings.


How Shipi18n Approaches Detection

When Shipi18n translates content, it doesn't just translate text—it parses and preserves placeholders. Here's the high-level logic:

1. Token Extraction

Before translation, we extract all placeholders from the source string:

// Input: "Thanks {{name}}, your order {{orderId}} is complete"
// Extracted: ["{{name}}", "{{orderId}}"]

We detect multiple placeholder formats:

FormatExampleCommon In
Double curly{{name}}i18next, Handlebars
Single curly{name}ICU, react-intl
Percent%s, %dC-style, Python
Dollar$name, ${name}Shell, some JS
Angle brackets<bold>text</bold>React components

2. Placeholder Preservation

During translation, placeholders are protected. The AI model receives instructions to preserve these tokens exactly. We verify post-translation that all placeholders survived intact.

3. Consistency Validation

After translation, we compare placeholder sets:

Source placeholders: {{name}}, {{orderId}}
Translation placeholders: {{name}}, {{orderId}}
Status: ✓ Match

If there's a mismatch, we flag it:

Issue: Placeholder mismatch
Key: checkout_success
Source: {{name}}, {{orderId}}
Found: {{nombre}}
Missing: {{name}}, {{orderId}}
Extra: {{nombre}}
Severity: Error

4. Sample Output

Here's what validation output looks like:

✓ auth.welcome - 2 placeholders preserved
✓ checkout.summary - 3 placeholders preserved
✗ checkout.success - Placeholder mismatch
    Expected: {{name}}, {{orderId}}
    Found: {{nombre}}
    Missing: {{orderId}}
✓ errors.generic - 0 placeholders (none expected)

Summary: 3 passed, 1 failed

This runs as part of the translation process, not as a separate lint step. You can't generate translations with broken placeholders.


Trade-offs and Limitations

Being honest about limitations builds more trust than pretending they don't exist.

False Positives

Some placeholder changes are intentional. If you're migrating from %s style to {{name}} style, the detector will flag these as mismatches. You'll need to handle the migration deliberately.

Dynamic Runtime Placeholders

If your placeholders are constructed at runtime, static detection can't help:

const key = `message_${type}`
t(key, dynamicParams)

We can't know what placeholders message_${type} contains until runtime. This is a general limitation of static analysis, not specific to placeholder detection.

Nested and Complex Structures

ICU MessageFormat supports complex nested structures:

{count, plural,
  =0 {No items}
  one {# item}
  other {# items}
}

These require ICU-aware parsing. Basic regex-based detection won't catch mismatches inside plural branches. Shipi18n handles common ICU patterns, but edge cases exist.

HTML and Component Interpolation

When translations include HTML or component placeholders:

"message": "Click <link>here</link> to continue"

The semantics get complex. Is <link> a placeholder? A component reference? It depends on your framework. We preserve these tokens, but validating their correctness requires framework-specific knowledge.


Who This Is For (and Not For)

Good Fit

  • Teams with 5+ target languages. More languages = more surface area for drift.
  • Apps with user-facing text interpolation. If you're showing "Hello {{name}}" to users, placeholders matter.
  • CI/CD pipelines where broken translations shouldn't ship. If you want to catch these before production.
  • Developers who've been burned by translation bugs. You know the pain. You want prevention.

Not a Fit

  • Fully static content. If your translations have no placeholders, this problem doesn't exist for you.
  • Single-language apps. No translations, no drift.
  • Teams with robust TMS workflows. If your translation management system already locks placeholders and your translators never make mistakes, you're covered.

Key Takeaways

  1. Placeholder mismatches are silent failures. They pass linting, pass tests, and break in production.

  2. This isn't a translator problem. It's a pipeline problem. Build detection into your tooling.

  3. Static analysis helps but has limits. You need placeholder-aware validation, not just JSON linting.

  4. Catch it at translation time. Don't wait for QA or production monitoring. Validate when translations are generated.


Next Steps

If you're dealing with placeholder drift:


Broken placeholders are preventable. Build the checks into your pipeline.

Get started with Shipi18n →

Does your CI check your translations?

One command, no account, no API key — the structural check runs anywhere. Apache-2.0, bring your own LLM for the semantic pass.

Star on GitHub →
S

Shipi18n Team

The Shipi18n team builds tools that help developers ship multilingual apps faster. We write about i18n best practices, localization automation, and translation engineering.