SR
8 min read

How We Fixed Video Uploads and Playback on a UGC Platform

Two backend problems that started small and quietly got worse: slow video uploads through our NestJS server and broken playback on iOS devices. Here is exactly how we fixed both using S3 pre-signed URLs, AWS MediaConvert, and SQS.

AWSS3MediaConvertSQSNestJSBackend Engineering

Some production problems announce themselves loudly. A service crashes, alerts fire, and you drop everything.

These two did not do that. They crept in quietly, looked manageable at first, and then turned into something that was visibly frustrating real users. One was a video upload that just felt slow. The other was a playback issue that made certain users see a broken video player every single time.

Both had clean fixes once we understood what was actually happening. Here is the full story.


The platform and what we were building

At Genesys Softwares, we built a user-generated content SaaS platform where businesses could collect video testimonials and marketing content from their users. Think of it like a Typeform for videos. A business drops a link, their customer records a short video on their phone or uploads one from their device, and the video appears in the business's dashboard ready to use.

The stack was NestJS on the backend, connected to AWS S3 for storage and a PostgreSQL database for metadata. The frontend was a Next.js app.

Simple enough in theory. In practice, two things did not go as planned.


Problem 1: Videos were traveling through our server when they didn't need to

The first upload flow we shipped worked like this:

  1. User selects a video file on the frontend
  2. Frontend sends the file to our NestJS backend via a multipart POST request
  3. The backend receives the full video in memory (or buffered to disk)
  4. The backend then pushes the file up to S3

It worked. But it was slow, especially for anything above a few megabytes. And the reason was obvious once we actually thought about it: every single video byte was traveling through our server twice. Once from the user's browser to our server, and then from our server to S3. We were just a middleman adding latency and burning our server's bandwidth doing nothing useful.

On top of the speed problem, larger files were putting real memory pressure on our backend processes during the upload phase. We were effectively queuing gigabytes of video data through a NestJS app that had better things to do.

The fix: S3 pre-signed URLs.

Instead of the backend receiving the video, the backend generates a temporary pre-signed URL and hands it to the frontend. The frontend then uploads the video directly to S3 using that URL. Our server is never in the data path at all.

// In our NestJS controller
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

@Injectable()
export class VideoUploadService {
  private s3 = new S3Client({ region: process.env.AWS_REGION });

  async getPresignedUploadUrl(
    filename: string,
    contentType: string,
    userId: string
  ): Promise<{ uploadUrl: string; objectKey: string }> {
    const objectKey = `uploads/${userId}/${Date.now()}-${filename}`;

    const command = new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME,
      Key: objectKey,
      ContentType: contentType,
    });

    const uploadUrl = await getSignedUrl(this.s3, command, {
      expiresIn: 900, // URL expires in 15 minutes
    });

    return { uploadUrl, objectKey };
  }
}

On the frontend side, the upload becomes a direct PUT request to S3:

// In the frontend upload handler
const { uploadUrl, objectKey } = await api.getPresignedUploadUrl({
  filename: file.name,
  contentType: file.type,
  userId: currentUser.id,
});

// Direct upload to S3 — our backend is not involved in this step
await fetch(uploadUrl, {
  method: "PUT",
  body: file,
  headers: {
    "Content-Type": file.type,
  },
});

// After S3 upload completes, notify the backend to kick off transcoding
await api.notifyUploadComplete({ objectKey });

The result was immediate. Upload speeds improved noticeably for users, and the backend CPU and memory load during uploads dropped significantly. We essentially removed ourselves from a data pipeline where we added zero value.


Problem 2: Videos were not playing on iOS and older macOS devices

This one hurt more because it only surfaced after deployment, and it was quiet at first. We started seeing reports of videos not loading on iPhones and older macOS browsers. The video thumbnail would appear, the user would hit play, and nothing would happen or it would throw an error.

The root cause was codec compatibility.

Videos submitted by users come in all formats. .mov from an iPhone, .webm from Chrome on Android, .avi from desktop apps, random .mp4 files encoded with all sorts of codecs. We were storing whatever came in and serving it back as-is.

That works fine on modern browsers with broad codec support. But iOS Safari, especially on older devices, is strict. It wants H.264 video with AAC audio in an MP4 container. If the video does not match that profile, it does not play.

Our initial instinct was to handle this ourselves with FFmpeg. The idea was straightforward enough: when a video lands in S3, kick off an FFmpeg transcoding job on the server, output an H.264 MP4, and store that as the playable version.

We prototyped it. It worked. And then we sat down and thought through what running FFmpeg in production actually meant:

  • Managing transcoding infrastructure and keeping it running
  • Scaling compute when lots of videos came in at once
  • Handling the backlog when jobs pile up
  • Debugging obscure encoding failures in production at 2 AM
  • Making sure in-progress jobs don't get killed when we deploy or restart services

We were not building a video transcoding company. We were building a UGC platform. These were not the problems we wanted to own.

So we dropped the FFmpeg plan and switched to AWS MediaConvert.


The transcoding pipeline we built

AWS MediaConvert is a managed file-based transcoding service. You give it an input file in S3, define the output format, and it handles everything. H.264, multiple bitrate renditions, thumbnail extraction, all of it. You pay per minute of transcoded video and it scales automatically, so a burst of 50 simultaneous uploads does not queue behind each other the way a fixed-size worker pool would.

But we didn't want our users blocking on a synchronous transcoding call either. Transcoding takes time. We needed the system to accept the upload, kick off transcoding in the background, and let users know when the video is ready.

That is where SQS came in.

Here is the flow we landed on:

User uploads video to S3
       |
       v
Frontend calls /api/videos/notify-upload
       |
       v
Backend creates a DB record (status: "processing")
Backend submits a transcoding job to AWS MediaConvert
       |
       v
MediaConvert transcodes the video asynchronously
MediaConvert sends a completion event via SNS
       |
       v
SNS delivers the event to an SQS queue
       |
       v
Our NestJS SQS consumer polls the queue
Consumer updates the DB record (status: "ready")
       |
       v
User's dashboard shows the video as available

The MediaConvert job submission looked something like this:

import {
  MediaConvertClient,
  CreateJobCommand,
} from "@aws-sdk/client-mediaconvert";

@Injectable()
export class TranscodingService {
  private mediaConvert = new MediaConvertClient({
    region: process.env.AWS_REGION,
    endpoint: process.env.MEDIACONVERT_ENDPOINT,
  });

  async submitTranscodingJob(
    inputKey: string,
    outputPrefix: string
  ): Promise<string> {
    const command = new CreateJobCommand({
      Role: process.env.MEDIACONVERT_ROLE_ARN,
      Settings: {
        Inputs: [
          {
            FileInput: `s3://${process.env.S3_BUCKET_NAME}/${inputKey}`,
            AudioSelectors: {
              "Audio Selector 1": { DefaultSelection: "DEFAULT" },
            },
          },
        ],
        OutputGroups: [
          {
            Name: "File Group",
            OutputGroupSettings: {
              Type: "FILE_GROUP_SETTINGS",
              FileGroupSettings: {
                Destination: `s3://${process.env.S3_BUCKET_NAME}/${outputPrefix}`,
              },
            },
            Outputs: [
              {
                ContainerSettings: { Container: "MP4" },
                VideoDescription: {
                  CodecSettings: {
                    Codec: "H_264",
                    H264Settings: {
                      RateControlMode: "QVBR",
                      MaxBitrate: 5000000,
                    },
                  },
                },
                AudioDescriptions: [
                  {
                    CodecSettings: {
                      Codec: "AAC",
                      AacSettings: {
                        Bitrate: 128000,
                        SampleRate: 48000,
                      },
                    },
                  },
                ],
              },
            ],
          },
        ],
      },
    });

    const response = await this.mediaConvert.send(command);
    return response.Job?.Id ?? "";
  }
}

And the SQS consumer that listened for MediaConvert completion events:

@Injectable()
export class TranscodingEventConsumer {
  constructor(private readonly videoService: VideoService) {}

  @SqsMessageHandler(process.env.TRANSCODING_QUEUE_NAME)
  async handleMessage(message: SQS.Message): Promise<void> {
    const body = JSON.parse(message.Body ?? "{}");
    const detail = JSON.parse(body.Message ?? "{}");

    if (detail.detail?.status === "COMPLETE") {
      const jobId = detail.detail?.jobId;
      await this.videoService.markVideoReady(jobId);
    }

    if (detail.detail?.status === "ERROR") {
      const jobId = detail.detail?.jobId;
      await this.videoService.markVideoFailed(jobId);
    }
  }
}

Once we had this in place, the system behaved exactly how we wanted. Upload completes, transcoding kicks off automatically, and when it finishes the SQS consumer updates the database record from processing to ready. The user's dashboard reflects that state.


What actually improved

After shipping both changes, three things got noticeably better:

Upload speed went up immediately for all users. Removing the server from the upload data path was the single biggest win. The bytes went straight from the user's browser to S3, which is exactly where they needed to end up anyway.

Playback compatibility became a non-issue. With every video transcoded to H.264 and AAC on the way in, we stopped getting reports of broken players on iOS or old macOS browsers. Every video in the system played on every device.

Server load dropped. No more holding large video files in memory during uploads. No more running FFmpeg processes in-process or on the same machines handling API requests. The backend went back to doing what it was supposed to: handle API logic, talk to the database, and delegate everything else.


What I took away from this

Both problems had a similar lesson at the root.

The first one was about recognizing when your architecture introduces waste. Our server was a pointless hop in the upload chain. Removing it was not clever engineering; it was just honest about what the server was actually contributing.

The second one was about knowing when to build versus when to buy. FFmpeg would have worked, and it would have been fine for a while. But managing the infrastructure around it, the workers, the queue, the scaling, the failure handling, was not core to what we were building. MediaConvert already solved all of that at a price that was genuinely low at our scale.

Backend engineering involves a lot of these calls. Not every problem needs a custom solution. Sometimes the most sophisticated thing you can do is recognize that someone else has already built this really well and point your system at it.

That is what we did here, and it paid off.

Enjoyed this? Let's build something together.

I take on select projects: SaaS products, backend systems and API-heavy applications. Get in touch and let's see if we're a good fit.

  • No obligation
  • Reply within 24 hours
  • NDA on request