Open source · Free npm package

GPX, TCX & FIT analytics
for runners

Parse GPX, TCX and FIT files, compute running metrics, and render Chart.js dashboards. Zero config — one API for every format.

.gpx .tcx .fitone API, auto-detected
Latest npm versionMonthly npm downloadsTypeScript types includedLicense

Parse, analyse, chart

A file path and one call for the metrics. Charts live behind a subpath, so parsing stays dependency-free.
import { parse, analyze } from '@alosha/stride'
// Charts are optional — install chart.js only if you render:
import { paceChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'

// Parse a GPX, TCX or FIT file from Garmin / Strava / Coros — format auto-detected
const activity = parse('./morning-run.fit')

// Compute every metric in one call
const stats = analyze(activity)
console.log(stats.distanceM, stats.avgPaceSecPerKm, stats.hrZones)

// Render a pace chart — Chart.js config returned, you own the canvas
new Chart(canvas, paceChartConfig(activity, stats))

Everything you need to visualise a run

One package, no wrappers around wrappers. Works in the browser and in Node.
  • GPX, TCX & FIT parser
    Parse GPX, TCX or FIT files — Garmin, Strava, Coros, Wahoo and more — auto-detected, with full HR, cadence, and elevation support.
  • Running metrics
    Distance, moving time, avg/best pace, elevation gain/loss, HR zones, cadence, and per-km splits — all in one call.
  • Device-accurate numbers
    Uses the watch’s own distance and barometric elevation when the file carries them, and denoises GPS altitude when it doesn’t — instead of integrating GPS jitter into inflated totals. A true rolling best-km, and time-weighted HR zones, not sample counts.
  • Chart.js configs
    Five ready-made chart configs (pace, elevation, HR, HR zones, splits) behind an optional subpath — chart.js stays out of your install unless you render. Browser or Node canvas.
  • Heart rate zones
    Automatic Z1–Z5 breakdown by %HRmax or heart-rate reserve (Karvonen). Time-weighted seconds in each zone, ready for a doughnut chart.
  • Metric & imperial
    All formatters and chart labels switch between km/min·km and mi/min·mi via a single units option.
  • CLI included
    Run `stride analyze run.gpx` to get a full activity summary in your terminal. No config needed.

CLI — analyse a run in seconds

Point it at a file and get a full activity summary in your terminal.
$ npx stride analyze morning-run.gpx

🏃 @alosha/stride — Morning Run

  Distance:      10.24 km
  Moving time:   51:32
  Elapsed time:  52:14
  Avg pace:      5:02/km
  Best km pace:  4:44/km
  Elevation ↑:   142m
  Elevation ↓:   138m
  Avg HR:        158 bpm
  Max HR:        178 bpm

  Splits:
    km  1  4:55/km  ↑12m  HR 152bpm
    km  2  5:03/km  ↑8m   HR 156bpm
    km  3  4:58/km  ↑5m   HR 159bpm
    ...

Should you parse it yourself?

Reading an activity file looks like a weekend job — until you meet the formats. Here is what you would actually take on, and what Stride absorbs.

Binary FIT decoding is a project, not a parse()

FIT is Garmin’s binary format — the native export from most watches. There is no XML to read: you decode a typed message stream and convert every field by hand.

What you’d own building it yourself

  • Binary message stream

    FIT is a compact binary protocol of definition and data messages — you need the Garmin SDK or a full decoder, not a string parser.

  • Semicircle coordinates

    Latitude and longitude are stored as int32 semicircles; miss the 180 / 2³¹ conversion and the GPS track lands in the ocean.

  • Cadence half-counts

    Running cadence is recorded per foot (RPM); double it to steps per minute or every cadence figure reads half what athletes expect.

With Stride: parse() auto-detects FIT, decodes the message stream with the official Garmin SDK, applies the semicircle conversion and cadence doubling, and returns the same normalised Activity you get from GPX or TCX — no per-format branching in your code.

The running metrics hide the real work

Distance and pace look trivial until pauses, GPS noise and zone boundaries turn each metric into a pile of edge cases.

What you’d own building it yourself

  • Moving vs elapsed

    Stop at a crossing and naive elapsed time wrecks your average pace — you need a speed threshold to separate moving time from pauses.

  • HR zone boundaries

    Z1–Z5 time-in-zone means bucketing every sample against %-of-max-HR thresholds and summing seconds — fiddly and easy to off-by-one.

  • Per-km splits

    Splits don’t fall on sample boundaries; you accumulate distance and time across points and emit a split exactly at each kilometre.

With Stride: analyze() returns distance, moving vs elapsed time, avg and best pace, per-km splits, elevation and Z1–Z5 HR zones in one call — the pause threshold, zone bucketing and split accounting are already handled and tested.

Build vs adopt: the engineering you’d take on

A rough estimate of the work to build and own equivalent parsing and metrics in-house — before the first bug report from a watch you never tested.

Build & own it yourself~12 dev-days

GPX/TCX/FIT decoders + metrics engine + 5 chart configs + ongoing format upkeep — rough estimate

@alosha/stride~0 days

npm install — parse(), analyze() and charts included

Production recipes

Real things you’d build with run data — solved with the published API.

Turn a Garmin .FIT upload into a pace chart in the browser

The problem: Users export runs from Garmin, Strava, Coros and Wahoo in different formats — and FIT is binary, not text.

import { parse, analyze } from '@alosha/stride'
import { paceChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'

// A user drops a .fit / .gpx / .tcx export onto your page.
async function renderUpload(file: File, canvas: HTMLCanvasElement) {
  const bytes = new Uint8Array(await file.arrayBuffer())
  const activity = parse(bytes)     // format auto-detected: GPX / TCX / FIT
  const stats = analyze(activity)   // distance, pace, HR zones, splits

  new Chart(canvas, paceChartConfig(activity, stats))
  return stats
}

Why it works: parse() auto-detects the format and returns one normalised Activity, so the same analyze() and chart configs work no matter which watch produced the file — no per-vendor branching in your upload handler.

Build a heart-rate zone breakdown without writing the maths

The problem: Time-in-zone is a core training metric, but computing Z1–Z5 from a raw HR stream by hand is fiddly and error-prone.

import { parse, analyze } from '@alosha/stride'
import { hrZonesChartConfig } from '@alosha/stride/charts'
import { Chart } from 'chart.js/auto'

const activity = parse('./tempo-run.tcx')
const stats = analyze(activity, { maxHR: 188 })   // %HRmax, or use a reserve model

// Time-weighted seconds in each zone, ready for a doughnut chart.
console.log(stats.hrZones)             // { z1, z2, z3, z4, z5 } | null
new Chart(canvas, hrZonesChartConfig(stats))

Why it works: analyze() computes Z1–Z5 time-in-zone from the HR stream — weighted by each sample’s duration, not counted — against the max HR you pass, and returns a ready Chart.js config. You get a training-quality breakdown without ever touching the zone formula.

Flag a negative split (or a late-race fade) from any run

The problem: Coaching and race-recap features want to know whether the second half was faster than the first — but pacing lives in the raw GPS stream, not in a tidy field.

import { parse, analyze, formatPace } from '@alosha/stride'

// Did the runner finish faster than they started? (a "negative split")
function splitAnalysis(activity) {
  const { splits } = analyze(activity)     // clean per-km splits
  if (splits.length < 2) return null

  const mid = Math.floor(splits.length / 2)
  const avgPace = (arr) =>
    arr.reduce((sum, s) => sum + s.paceSecPerKm, 0) / arr.length

  const firstHalf = avgPace(splits.slice(0, mid))
  const secondHalf = avgPace(splits.slice(mid))
  const deltaSec = Math.round(firstHalf - secondHalf)  // > 0 => back half quicker

  return {
    negativeSplit: deltaSec > 0,
    firstHalfPace: formatPace(firstHalf),    // "6:00/km"
    secondHalfPace: formatPace(secondHalf),  // "5:00/km"
    swingSecPerKm: Math.abs(deltaSec),
  }
}

Why it works: analyze() already emits clean per-km splits, so classifying the run is a couple of array reductions over stats.splits and a formatPace() call — you never re-derive pace from raw GPS points or reinvent the split accounting.

Built to pass a dependency review

The questions a CTO asks before adding a package to production — answered up front.
Metric / concern What ships
FormatsGPX · TCX · FIT
Data isolationParsed locally, never uploaded
RuntimeBrowser + Node
ChartsSide-effect-free configs
Dependencieschart.js + 2 parsers
Type safetyShips .d.ts (ESM + CJS)
LicensingMIT

Add Stride to your project

Stride is free and MIT-licensed. When you need a hosted feature, a custom build, or a fast answer from the person who wrote it, there is a paid path — backed by the founder, not a ticket queue.
  • Custom chart types or running metrics for your product
  • Help integrating Stride into your app or platform
  • Priority fixes and feature requests from the maintainer