Building a GPS file converter that never uploads your files
Every online converter for fitness files works the same way: you upload your activity, a server parses it, you download the result. For a GPX track that is a strange deal to accept. The file is a precise log of where you
Every online converter for fitness files works the same way: you upload your activity, a server parses it, you download the result. For a GPX track that is a strange deal to accept. The file is a precise log of where you live, where you train, when you leave the house, and how hard your heart was working the whole time.
I built GPX Converter so that upload step does not exist. Everything runs in the browser.
The three formats you actually meet
- FIT β Garmin's binary format. Compact (a few bytes per sample), carries laps, session summaries, developer fields. A 10 MB FIT can hold months of data.
-
GPX β plain XML, one
<trkpt>per sample. Universal: Strava, Komoot, RideWithGPS, QGIS, Google Earth all read it. - TCX β Garmin Training Center XML. The awkward middle child: XML like GPX, but with lap and session structure like FIT.
Converting FIT to GPX inflates the file roughly 5-8x, because you trade binary packing for human-readable XML. That is not a bug, it is the format.
Architecture that makes uploads impossible
No upload endpoint exists. The whole pipeline is client-side:
-
File never leaves memory.
File.arrayBuffer()gives you the bytes. There is nowhere to send them. - Parsing runs in a dedicated Web Worker, so a 50 MB file does not lock the UI thread. The main thread only handles drag-and-drop, progress and rendering.
- Heavy parsers load lazily inside the worker, keeping them out of the initial page bundle.
- Output is handed back as a Blob and downloaded straight from memory.
You can verify it: open devtools, hit the network tab, drop a file. No request carries file bytes.
The trap nobody warns you about: DOMParser does not exist in a Worker
This one cost me a release. In a Worker there is no window, no document, and therefore no DOMParser. GPX and TCX are XML, so the obvious parser throws:
Uncaught ReferenceError: DOMParser is not defined
FIT is unaffected because it is binary β you walk the header, read definition messages, decode record messages by field type. So the bug hides: everything looks fine if you only test with .fit input, and every XML path fails at runtime with an error the user reads as "this tool is broken".
Two good fixes:
Option A β parse in the main thread, compute in the worker.
// main thread
const text = await file.text();
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('Invalid XML');
const points = [...doc.querySelectorAll('trkpt')].map(el => ({
lat: +el.getAttribute('lat'),
lon: +el.getAttribute('lon'),
ele: num(el.querySelector('ele')?.textContent),
time: el.querySelector('time')?.textContent ?? null,
hr: num(el.getElementsByTagNameNS(NS_TPX, 'hr')[0]?.textContent),
}));
worker.postMessage({ points }, []);
The structuredClone the worker receives is cheap, and serialization stays in the worker where it belongs.
Option B β use a worker-safe XML parser. Something like fast-xml-parser has no DOM dependency, ships small, and keeps all parsing inside the worker. Faster on large tracks, slightly more code to map Garmin's TrackPointExtension namespaces yourself.
I went with A: one parse, structured data across the wire, no extra dependency.
Detail loss is the real engineering problem
Formats are not equally expressive:
| Data | FIT | GPX | TCX |
|---|---|---|---|
| Position + elevation | yes | yes | yes |
| Heart rate | yes | via gpxtpx extension |
yes |
| Cadence / power | yes | via extensions | via TPX
|
| Laps / intervals | full | lost (segments only) | yes |
| Session summary | yes | no | yes |
So a converter should tell you what it dropped instead of pretending the round-trip is lossless. Heart rate and cadence survive into GPX through the Garmin TrackPointExtension, which is why Strava shows them on a converted file. Lap metadata does not survive FIT to GPX β only the segment boundaries do. Saying that out loud is more useful than a green checkmark.
Repairing a corrupt FIT file
A full memory card, a battery death mid-ride, or a bad transfer leaves a FIT file that Garmin Connect and Strava both refuse β while the ride sits inside it. The header says how long the file should be, the file carries two CRCs, and any mismatch makes a strict parser bail on message one.
Rebuilding is mechanical: rewrite the 14-byte header's data-length field, recompute the header CRC, walk the definition/record messages until the bytes run out (keeping the last complete message), then recompute the trailing file CRC. In a test on a real 700 KB activity file truncated to 90%, that recovered 11,667 record messages and 11 unreadable bytes dropped.
Why local-first is the right default
Not speed (though there is no queue and it works on a plane). Not cost (though there is no server bill per conversion). The reason is that the sensitivity of GPS data is unusually high and the value of server-side processing is unusually low. Parsing XML and binary records is not a computation that needs a datacenter β a phone does it in milliseconds.
If your file format is open and your workload is parse-transform-serialize, the browser is a perfectly good runtime. The only thing you give up is the ability to see your users' data, which for this category is the whole point.
The tools are free and account-free at gpxconverter.com β FIT/GPX/TCX conversion, merge, split, FIT repair and map preview, all client-side.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.