DEV Community

Emily digiplanPro
Emily digiplanPro

Posted on

What I Learned Building an Export Pipeline for a Transcription App


When I started building a transcription app, exporting looked like one of the easiest features.

The transcript was already stored in the database. I only needed to turn it into a text file and send it to the browser.

That worked for TXT.

Then I added subtitles, editable speaker names, timestamps, DOCX files, and automatic saving. Suddenly, exporting was no longer a small utility function. It became a consistency problem.

The difficult question was not:

How do I create an SRT file?

It was:

How do I guarantee that every export contains the latest version of the transcript?

This post covers the design decisions that helped make the export workflow more reliable.

Start with structured transcript data

A transcript should not be stored as one large string if it needs timestamps, speakers, subtitles, or synchronized playback.

A more useful structure is an ordered list of segments:

type TranscriptSegment = {
  id: string;
  startTimeMs: number;
  endTimeMs: number;
  speakerId: string | null;
  text: string;
};

type Speaker = {
  id: string;
  name: string;
};

type Transcript = {
  id: string;
  version: number;
  durationMs: number;
  speakers: Speaker[];
  segments: TranscriptSegment[];
};
Enter fullscreen mode Exit fullscreen mode

This structure provides enough information to generate several outputs from the same source:

  • TXT
  • Markdown
  • SRT
  • VTT
  • CSV
  • JSON
  • DOCX
  • PDF

It also keeps the editing interface and export pipeline aligned. The same segment used to highlight the current sentence during playback can later become a subtitle cue.

Keep time values in milliseconds

It is tempting to store timestamps as strings such as 00:04:12.500.

That format is readable, but it is inconvenient for calculations.

Milliseconds are easier to compare, sort, validate, and convert:

const segment: TranscriptSegment = {
  id: "segment-42",
  startTimeMs: 252_500,
  endTimeMs: 258_200,
  speakerId: "speaker-2",
  text: "We should move the release to Friday."
};
Enter fullscreen mode Exit fullscreen mode

Formatting should happen only when an output requires it.

For SRT:

function formatSrtTime(milliseconds: number): string {
  const totalSeconds = Math.floor(milliseconds / 1000);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  const ms = milliseconds % 1000;

  return [
    String(hours).padStart(2, "0"),
    String(minutes).padStart(2, "0"),
    String(seconds).padStart(2, "0")
  ].join(":") + `,${String(ms).padStart(3, "0")}`;
}
Enter fullscreen mode Exit fullscreen mode

For WebVTT, the comma becomes a period:

function formatVttTime(milliseconds: number): string {
  return formatSrtTime(milliseconds).replace(",", ".");
}
Enter fullscreen mode Exit fullscreen mode

Keeping one internal time representation prevents conversion bugs from spreading through the application.

Validate segments before generating files

Speech-recognition output is not always perfectly clean.

A segment may have an invalid duration. Two segments may overlap. Editing operations may accidentally leave an empty segment in the transcript.

The export layer should not assume that every input is valid.

function validateSegments(segments: TranscriptSegment[]): string[] {
  const errors: string[] = [];

  for (let index = 0; index < segments.length; index++) {
    const segment = segments[index];

    if (!segment.text.trim()) {
      errors.push(`Segment ${segment.id} has no text.`);
    }

    if (segment.startTimeMs < 0) {
      errors.push(`Segment ${segment.id} starts before zero.`);
    }

    if (segment.endTimeMs <= segment.startTimeMs) {
      errors.push(`Segment ${segment.id} has an invalid duration.`);
    }

    const next = segments[index + 1];

    if (next && segment.startTimeMs > next.startTimeMs) {
      errors.push("Segments are not ordered by start time.");
    }
  }

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

Whether overlapping segments should be rejected depends on the product.

Overlaps may be valid when two people speak simultaneously. For simple subtitle exports, however, they may produce poor results. In that case, the exporter can adjust timings or warn the user.

The important point is to make this behavior explicit.

Separate transcript data from presentation data

The editor may display speaker badges, confidence indicators, highlighted search terms, or paragraph breaks.

Those details should not leak into the export logic.

Exporters should receive clean domain data:

type ExportContext = {
  transcript: Transcript;
  includeSpeakers: boolean;
  includeTimestamps: boolean;
};
Enter fullscreen mode Exit fullscreen mode

A TXT exporter can then decide how to represent it:

function exportTxt(context: ExportContext): string {
  const speakerMap = new Map(
    context.transcript.speakers.map(speaker => [
      speaker.id,
      speaker.name
    ])
  );

  return context.transcript.segments
    .map(segment => {
      const parts: string[] = [];

      if (context.includeTimestamps) {
        parts.push(`[${formatReadableTime(segment.startTimeMs)}]`);
      }

      if (context.includeSpeakers && segment.speakerId) {
        const speakerName =
          speakerMap.get(segment.speakerId) ?? "Unknown speaker";

        parts.push(`${speakerName}:`);
      }

      parts.push(segment.text.trim());

      return parts.join(" ");
    })
    .join("\n\n");
}
Enter fullscreen mode Exit fullscreen mode

This keeps the exporter independent of React components, CSS classes, and editor state.

Save before export

This was the source of one of the most confusing bugs in the app.

A user edited a sentence and immediately clicked Export. The downloaded file contained the previous version.

The editor used debounced autosaving:

const saveChanges = debounce(async (segments) => {
  await updateTranscript(segments);
}, 1000);
Enter fullscreen mode Exit fullscreen mode

The visible editor state was newer than the saved server state. The export endpoint generated its file from the database, so the most recent edits were missing.

The solution was to make pending changes flushable.

type PendingSave = {
  segments: TranscriptSegment[];
  resolve: () => void;
  reject: (error: unknown) => void;
};
Enter fullscreen mode Exit fullscreen mode

Before starting an export, the client now waits for the current save operation:

async function handleExport(format: ExportFormat): Promise<void> {
  await transcriptEditor.flushPendingChanges();

  const response = await fetch(
    `/api/transcripts/${transcriptId}/exports/${format}`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      }
    }
  );

  if (!response.ok) {
    throw new Error("The export could not be generated.");
  }

  const blob = await response.blob();
  downloadBlob(blob, createFilename(format));
}
Enter fullscreen mode Exit fullscreen mode

The export button also displays a temporary state:

  • Saving changes
  • Preparing export
  • Downloading
  • Export complete
  • Export failed

This is more useful than showing one generic loading spinner.

Add version checks

Waiting for a save solves most cases, but versioning makes the system safer.

Each successful transcript update increments a version number:

type TranscriptUpdateResponse = {
  transcriptId: string;
  version: number;
  updatedAt: string;
};
Enter fullscreen mode Exit fullscreen mode

The client includes that version when requesting an export:

await fetch(`/api/transcripts/${transcriptId}/exports/srt`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    expectedVersion: transcriptVersion
  })
});
Enter fullscreen mode Exit fullscreen mode

The server verifies that it is exporting the expected version:

if (transcript.version !== request.expectedVersion) {
  return new Response(
    JSON.stringify({
      error: "TRANSCRIPT_VERSION_CONFLICT",
      currentVersion: transcript.version
    }),
    {
      status: 409,
      headers: {
        "Content-Type": "application/json"
      }
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

This protects against stale browser tabs, concurrent editing, and race conditions between saving and exporting.

Generate SRT from segments

Once the data is validated, SRT generation is straightforward:

function exportSrt(transcript: Transcript): string {
  return transcript.segments
    .filter(segment => segment.text.trim())
    .map((segment, index) => {
      return [
        index + 1,
        `${formatSrtTime(segment.startTimeMs)} --> ${formatSrtTime(
          segment.endTimeMs
        )}`,
        segment.text.trim()
      ].join("\n");
    })
    .join("\n\n");
}
Enter fullscreen mode Exit fullscreen mode

A basic result looks like this:

1
00:00:02,400 --> 00:00:05,700
Thanks for joining the meeting.

2
00:00:06,100 --> 00:00:10,200
Today we need to finalize the release date.
Enter fullscreen mode Exit fullscreen mode

Real subtitle exports may need additional processing.

Very long transcript segments should be split into shorter cues. Extremely short segments may need to be merged. Line lengths may need limits for readability.

These transformations should happen in a dedicated subtitle-normalization stage rather than modifying the original transcript.

const subtitleSegments = normalizeForSubtitles(
  transcript.segments,
  {
    maxCharactersPerCue: 84,
    minimumDurationMs: 800,
    maximumDurationMs: 7_000
  }
);
Enter fullscreen mode Exit fullscreen mode

The transcript remains unchanged, while the subtitle output is optimized for its own use case.

Treat every export format as an adapter

I originally wrote one large export function with several conditional branches:

if (format === "txt") {
  // ...
} else if (format === "srt") {
  // ...
} else if (format === "json") {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

It became difficult to test as formats were added.

A small adapter interface worked better:

type ExportResult = {
  content: Uint8Array;
  contentType: string;
  extension: string;
};

interface TranscriptExporter {
  export(context: ExportContext): Promise<ExportResult>;
}
Enter fullscreen mode Exit fullscreen mode

Each format has its own implementation:

const exporters: Record<ExportFormat, TranscriptExporter> = {
  txt: new TxtExporter(),
  srt: new SrtExporter(),
  vtt: new VttExporter(),
  json: new JsonExporter(),
  csv: new CsvExporter(),
  docx: new DocxExporter(),
  pdf: new PdfExporter()
};
Enter fullscreen mode Exit fullscreen mode

The API handler stays small:

const exporter = exporters[format];

if (!exporter) {
  return new Response("Unsupported export format", {
    status: 400
  });
}

const result = await exporter.export(context);
Enter fullscreen mode Exit fullscreen mode

This also makes unit testing much simpler.

Use the same model for audio and video

The input may be an MP3 interview or an MP4 webinar, but the output model does not need to change.

After media processing, both become:

  • a duration
  • a list of timed transcript segments
  • optional speaker data
  • editable text

For users, there may be separate entry points such as MP3 to Transcript for audio and MP4 to Transcript for video. Internally, however, both can use the same editor, validation rules, and exporters.

Keeping the post-processing workflow format-independent reduces duplication and makes new input formats easier to support.

Test the generated files, not only the functions

A unit test that checks whether an SRT string contains a timestamp is useful, but it is not enough.

Export tests should cover complete files:

describe("SrtExporter", () => {
  it("exports ordered subtitle cues", async () => {
    const result = await exporter.export(testContext);
    const content = new TextDecoder().decode(result.content);

    expect(content).toContain(
      "00:00:02,400 --> 00:00:05,700"
    );

    expect(content).toContain(
      "Thanks for joining the meeting."
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

I also keep a small set of manual test transcripts:

  • one speaker
  • multiple speakers
  • Unicode characters
  • right-to-left text
  • empty segments
  • overlapping speech
  • a multi-hour recording
  • very long paragraphs
  • timestamps near an hour boundary

Generated DOCX and PDF files should be opened in real viewers. Subtitle files should be imported into at least one video player or editor.

A file can be technically generated without being practically usable.

What I would design differently today

I would treat exporting as part of the core transcript architecture from the beginning.

That means:

  1. Store structured, time-aligned segments.
  2. Use milliseconds internally.
  3. Keep editor UI data out of the domain model.
  4. Validate before exporting.
  5. Flush pending edits first.
  6. Check transcript versions.
  7. Give each output format its own adapter.
  8. Test actual generated files.

Exporting is not simply the last button in the workflow.

It is the point where users expect the application to return a trustworthy copy of their work. If the file contains stale text, broken timestamps, or incorrect speaker names, the rest of the transcription experience no longer matters.

The most reliable export pipeline is the one that treats consistency as a feature rather than an implementation detail.

Top comments (0)