On-Device Translation with Apple's Translation Framework (The Session You Can't Keep)
FeedbackKit collects feature requests, votes and comments from inside our customers' apps, and the product teams who triage that feedback do it in our Admin app. It arrives in whatever language the person writing it thinks in: a German product manager opens the board and finds requests in English, Japanese and Portuguese.
Before this feature they had two options: guess from the shape of the words, or paste the text into a translation site. The second is the real problem. That text is user-authored content belonging to somebody else's customer, and a web form ships it to a third party nobody in the chain agreed to.
So: translate on device. Titles, descriptions, rejection reasons, comments, with a one-tap path back to the original. Apple's Translation framework does exactly that — no server, no API key, no per-character billing, and the text never leaves the device; Apple's stated position is that it collects usage metrics only, never content. That part was easy. What shaped the design was the session lifetime, so that is where this starts.
You do not own the session
The obvious design writes itself: a Translator object with a translate(_:) method, injected wherever you need translated text, managing a session internally. Every dependency-injection instinct points that way.
It is not possible. A TranslationSession is produced by SwiftUI's .translationTask(_:action:) modifier and is valid only for the duration of the action closure. Apple documents that using one after the view disappears, or after the configuration it came from changes, is a hard fatalError — not a thrown error you can catch, not a nil you can guard, a crash. The session belongs to the view modifier, not to you.
Which inverts the design. The translator cannot hold a session, so it holds the two things it can outlive: the configuration that causes a session to exist, and a queue of work waiting for one. The session arrives as a parameter and is never stored.
@MainActor @Observable
final class FeedbackTranslator {
/// Handed to `.translationTask`. Replacing this value is
/// what causes SwiftUI to vend a new session.
private(set) var configuration: TranslationConfiguration?
private var pending: [Batch] = []
/// The session is a parameter. It is never a property,
/// never captured, never escapes this call.
func run(_ session: TranslationSession) async {
guard let batch = pending.first else { return }
do {
let responses = try await session
.translations(from: batch.requests)
store(responses, for: batch)
} catch {
markFailed(batch)
}
advance()
}
}The call site is three lines, and the important thing about it is that the view is the only place a session is ever mentioned:
FeedbackList(items: items)
.translationTask(translator.configuration) { session in
await translator.run(session)
}One source language per batch
A feedback board is mixed-language by definition — that is the entire reason the feature exists. But a TranslationConfiguration carries exactly one source language and one target language, and Apple is blunt about what happens if you lie about the source: a batch mixing languages "is likely to output nonsense", as the documentation puts it.
So a screenful of feedback is not one translation job. It is N jobs, one per distinct source language on screen, each needing its own configuration — and since only one configuration is live at a time, they run sequentially.
The queue is the configuration
There is no API for "run this batch". The only lever is the configuration value, so the queue advances by assigning a new one — which re-fires the modifier, which produces a fresh session, which calls back into run(_:).
private func advance() {
pending.removeFirst()
guard let next = pending.first else {
configuration = nil // let the session go
return
}
// A *different* configuration value is the signal.
// Same pair twice in a row would not re-fire the task.
configuration = TranslationConfiguration(
source: next.sourceLanguage,
target: targetLanguage
)
}The comment is load-bearing. SwiftUI re-runs the task when the configuration changes, so consecutive batches must differ — which they do here, because batches are keyed by source language and each language appears once. If you genuinely need to re-run an identical pair, TranslationConfiguration has invalidate() for it.
Give each request a clientIdentifier encoding the item and field it came from, too. Responses carry it back, which beats assuming the arrays line up.
Detecting the source, per field
Source detection is NLLanguageRecognizer, and the only interesting decision is granularity. We detect per field, not per item, because one feedback item can legitimately have an English title over a German body — a bilingual user writing the headline for the team and the detail in their own language. Detecting once per item gets one of the two fields wrong.
The second decision is the confidence gate:
func detectedLanguage(of text: String) -> Locale.Language? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
guard let (language, confidence) = recognizer
.languageHypotheses(withMaximum: 1).first,
confidence >= 0.60
else { return nil }
return Locale.Language(identifier: language.rawValue)
}A nil result renders no affordance at all — no "Translate from German" button, no language label, nothing. Short feedback titles are genuinely hard to classify, and a five-word title routinely comes back as a coin flip between two Romance languages. A confident label that is wrong is worse than no label, because the label is the part the reader will believe.
Caching, with a fingerprint
Translation is fast but not free, and a triage screen gets scrolled, filtered and re-entered constantly. The cache key is the obvious tuple — item, field, target language — and each entry stores something less obvious next to the translated string: a hash of the source text it came from.
struct CacheKey: Hashable {
let itemID: UUID
let field: Field
let target: Locale.Language
}
func cached(_ key: CacheKey, source: String) -> String? {
guard let entry = store[key] else { return nil }
guard entry.sourceHash == source.hashValue else {
store[key] = nil // source changed underneath us
return nil
}
return entry.translation
}Without the fingerprint, an author editing their feature request produces a cache hit on the old text, and the reader is served a confident translation of something nobody wrote any more. With it, an edit misses and evicts in the same lookup.
Note that String.hashValue is seeded per process, so the same string hashes differently tomorrow. That would be a bug if the cache were persisted. It is not, and never will be.
In-memory only, on purpose
FeedbackKit ships as a library inside somebody else's app. Anything it writes to disk lands in the host app's container, under the host app's data-protection class, in the host app's backups, and inside the host app's answer to a deletion request. A library that quietly persists end-user content is rewriting its host's privacy posture, and the host developer finds out when someone asks them where that data lives.
So the cache dies with the process. The cost is re-translating after a cold launch — a fraction of a second of on-device work. The alternative is an SDK that turns its adopters into data controllers for a store they did not know they had.
The platform guard that picks the arm that cannot compile
This one cost real time, and it is the reason this post exists in the shape it does.
The reflex for optional system frameworks is canImport:
#if canImport(Translation) // WRONG on visionOS
import Translation
// ...every symbol here is unavailable on visionOS
#endifvisionOS ships a stub Translation.framework. The module is present, so canImport(Translation) is true and the compiler takes that branch — and every symbol inside it is marked @available(visionOS, unavailable), so the branch you just selected is the one that cannot possibly build. The guard does not merely fail to protect you; it steers into the wall.
The fix is a compound positive guard naming the platforms where the framework is real, plus an #else declaring an identical, platform-neutral surface:
#if canImport(Translation) && (os(iOS) || os(macOS))
import Translation
extension FeedbackTranslator {
var isTranslationSupported: Bool { true }
func offer(for field: Field) -> TranslationOffer? { ... }
}
#else
// Same names, same signatures, no Translation import.
extension FeedbackTranslator {
var isTranslationSupported: Bool { false }
func offer(for field: Field) -> TranslationOffer? { nil }
}
#endifThe #else arm matters as much as the guard. Because both arms declare the same API, no call site in the SDK or the Admin app contains a platform conditional: views ask isTranslationSupported, get false on visionOS, and render no button. Leaking #if into view bodies would spread the trap across the codebase instead of containing it in one file.
Never translate a language into itself
Apple never supports same-language pairs, including ones a human would consider distinct: en-GB to en-US is not a translation job, it is an error. So you check before you offer. The naive check is one line, and it silently does nothing:
// Looks right. Fails to skip.
guard source != target else { return nil }Locale.Language equality is region-sensitive. NLLanguageRecognizer hands you a bare en. A device in the United States reports en_US. Those two values are not equal, so the guard passes, a configuration is built for en to en, and you get an error back from a call that should never have been made. Compare the language codes instead:
func canTranslate(from source: Locale.Language,
to target: Locale.Language) -> Bool {
// `en` != `en_US`, so compare codes, not values.
source.languageCode != target.languageCode
}Choosing the target language
The reader picks what feedback gets translated into, and that list is a runtime question. LanguageAvailability().supportedLanguages returns what the device actually supports — 21 languages at the time of writing, and Apple states plainly that the set grows. Hardcoding it ships an app that gets worse every OS release.
Membership in that list is necessary but not sufficient: support is a property of the pair, not of either language alone, so every combination needs its own check.
switch await LanguageAvailability().status(from: source, to: target) {
case .installed: offerImmediately()
case .supported: offerWithDownloadPrompt()
case .unsupported: renderNoAffordance()
@unknown default: renderNoAffordance()
}Ordering matters more than it sounds. An alphabetical list of 21 languages buries the two or three the reader actually reads. Locale.preferredLocales is the system's own answer, and sorting by it floats those to the top the way Apple's Translate app does:
let preferred = Locale.preferredLocales.compactMap(\.language.languageCode)
func rank(_ language: Locale.Language) -> Int {
language.languageCode
.flatMap(preferred.firstIndex(of:)) ?? .max
}
let ordered = supported.sorted { rank($0) < rank($1) }Model downloads happen once, not mid-scroll
prepareTranslation() makes sure the model for a pair is present, and if it is not, the user gets a system permission sheet. That is a modal interruption, so it runs at most once per screen entry, on entry, never lazily as rows scroll into view. From a scroll handler it puts a sheet on screen while the user's thumb is moving, which reads as a bug even when it is working correctly.
The consolation is that models are shared system-wide. Someone who already translates German in Safari or Messages has German, and your app costs them nothing to enable.
What we deliberately do not translate
Product vocabulary is not machine-translated, and that is a decision rather than an oversight. Status names, category names, role names, tier names and integration provider names all come from String Catalogs, translated once, by people, as part of the product.
Machine-translating them too would put two systems' words for one concept on the same screen. The status chip would carry the String Catalog's translation of "Planned", while the translated body text underneath — where the author happened to write "planned" — would carry whatever the model produced. The reader sees two different words for one thing and reasonably concludes they mean two different things.
The rule that came out of it: translate content, never chrome. Anything the product names is chrome, and chrome is a localisation problem with an established solution.
The limits, stated plainly
This is an Apple-platforms feature and does not pretend otherwise. The honest list:
- iOS and macOS only. visionOS ships the stub described above, and tvOS and watchOS have nothing. Anything cross-platform needs a different answer entirely.
- It runs in neither the iOS Simulator nor SwiftUI Previews. Both need the on-device models, and neither has them.
- Which means no CI coverage of the translation path. Our tests cover detection, batching, cache eviction and the guard surface — everything around the framework — and the framework itself sits behind a manual device gate before release.
- 21 languages at the time of writing, not "any language". Read it as a runtime query, never as a constant.
- Apple publishes no batch-size guidance. We measured on the oldest device we support and picked a size from that, which is not the same as knowing the right answer.
On that last point, iOS 26.4 added TranslationSession.Strategy, which lets you ask for .highFidelity or .lowLatency rather than accepting whatever you get. Worth knowing before you adopt it: building against the 26.4 or later SDK appears to change the default on Apple Intelligence devices, so an app can behave differently after nothing more than an Xcode upgrade. Set it explicitly — an inherited default that moves with your toolchain is not a default, it is a variable.
What surprised me
Two things, and both are the same category: the compiler and the type system agreed with code that was wrong.
The visionOS stub is the sharper of the two. I had written canImport guards for years on the assumption that a module either exists and works or does not exist. A module that exists specifically so every symbol in it can be marked unavailable was a new shape, and the failure mode is perfect: the guard is true, the branch compiles right up until it does not, and the error talks about availability rather than about the guard that chose the branch.
The Locale.Language equality trap is quieter and probably more common in shipped code. source != target reads like a correct check, passes review, produces no warning — and because a bare en and an en_US are unequal values, never skips the case it exists to skip. Any code comparing Locale.Language values for semantic equality deserves a second look; the answer is almost always languageCode.
If I were starting again I would write the #else arm first. Building the platform-neutral surface before the real implementation forces the call-site question — what does a view do where translation does not exist — to the front, instead of surfacing it as a build failure on a platform you were not thinking about.
The short version
The Translation framework is a good deal: real quality, no server, no key, no per-character cost, and user content that stays on the device it arrived on. The price is that it does not fit the object graph you would have drawn. The session is owned by a view modifier and dies with it, so what you build is a configuration plus a queue, and the session is a parameter you borrow and hand back.
Get that inversion right and the rest is bookkeeping: group by detected source language, gate detection on confidence, fingerprint the cache, guard the platform positively, and compare language codes rather than languages.