Preset

s3 latency profile

The 's3' preset models the command overhead (metadata, signature, routing) — not the transfer time of the object body, which depends on file size and bandwidth. For small objects (<1MB), the preset captures the dominant latency factor.

presetp50p95p99errorRate
s3 30ms 200ms 500ms 0.1%
S3 latency vs transfer time

The preset covers the API round-trip latency — the time before you start receiving bytes. For large file uploads/downloads, transfer time dominates. To model a 5MB file upload over a typical connection, add the expected transfer time to the p50/p99 values.

Quick start

Wrap s3Client.send

The AWS SDK v3 uses a universal client.send(command) pattern. Wrapping send covers all operations — PutObject, GetObject, DeleteObject, ListObjects — in one line.

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { withLatency } from 'slowdep';

const s3 = new S3Client({ region: process.env.AWS_REGION });

// Wrap the universal send method — covers every S3 command
const slowSend = withLatency(s3.send.bind(s3), 's3');

// Upload a file
await slowSend(new PutObjectCommand({
  Bucket: process.env.S3_BUCKET,
  Key: `uploads/${userId}/avatar.png`,
  Body: imageBuffer,
  ContentType: 'image/png'
}));

// Download a file
const response = await slowSend(new GetObjectCommand({
  Bucket: process.env.S3_BUCKET,
  Key: `uploads/${userId}/avatar.png`
}));
Individual wrapping

Wrap specific command functions

If you prefer, wrap helper functions that construct and send specific commands. This gives you more control over latency profiles per operation type.

import {
  S3Client, PutObjectCommand, GetObjectCommand,
  DeleteObjectCommand, ListObjectsV2Command
} from '@aws-sdk/client-s3';
import { withLatency } from 'slowdep';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.S3_BUCKET!;

// Build typed wrappers around each operation
async function putObject(key: string, body: Buffer | string, contentType: string) {
  return s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType }));
}

async function getObject(key: string) {
  return s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
}

async function deleteObject(key: string) {
  return s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}

// Wrap each function individually — easy to disable per operation
export const storage = {
  put:    withLatency(putObject, 's3'),
  get:    withLatency(getObject, 's3'),
  delete: withLatency(deleteObject, 's3'),
};

// Clean, typed, realistic
await storage.put('docs/report.pdf', pdfBuffer, 'application/pdf');
const file = await storage.get('docs/report.pdf');
Presigned URLs

Test the presigned URL upload flow

Many apps generate presigned URLs server-side and upload client-side. The presigned URL generation is the S3 API call — wrap getSignedUrl to test the server's role in the flow.

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { withLatency } from 'slowdep';

const s3 = new S3Client({ region: process.env.AWS_REGION });

// getSignedUrl is a standalone async function — wrap it directly
const slowGetSignedUrl = withLatency(getSignedUrl, 's3');

// Server-side: generate a presigned URL for the client to upload to
async function generateUploadUrl(key: string, contentType: string) {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: contentType
  });

  // This is the S3 API call — it returns a signed URL, not the file
  const url = await slowGetSignedUrl(s3, command, { expiresIn: 3600 });
  return url;
}

// Test: does your upload endpoint respond in time with the presigned URL?
const uploadUrl = await generateUploadUrl(`avatars/${userId}.jpg`, 'image/jpeg');
Testing

Test upload timeout and retry

import { withLatency } from 'slowdep';
import { uploadAvatar } from '../src/uploads';

test('uploadAvatar times out after 2s and returns error', async () => {
  // Simulate S3 under load or large file transfer
  const slowSend = withLatency(
    jest.fn().mockResolvedValue({ ETag: '"abc123"' }),
    { p50: 3000, p99: 8000 }
  );

  await expect(
    uploadAvatar(userId, imageBuffer, { send: slowSend, timeoutMs: 2000 })
  ).rejects.toThrow('UploadTimeoutError');
}, 5000);
import { withLatency } from 'slowdep';

test('upload retries on S3 503 SlowDown and succeeds', async () => {
  const slowDownError = Object.assign(
    new Error('SlowDown'),
    { name: 'SlowDown', $retryable: { throttling: true } }
  );

  const mockSend = jest.fn()
    .mockRejectedValueOnce(slowDownError)
    .mockResolvedValueOnce({ ETag: '"abc123"', VersionId: 'v1' });

  const slowSend = withLatency(mockSend, 's3');

  const result = await uploadWithRetry(slowSend, imageBuffer, {
    maxRetries: 3,
    backoffMs: 500
  });

  expect(result.ETag).toBe('"abc123"');
  expect(mockSend).toHaveBeenCalledTimes(2);
});
Production safety

Conditional wrapping with env check

import { S3Client } from '@aws-sdk/client-s3';
import { withLatency } from 'slowdep';

const s3 = new S3Client({ region: process.env.AWS_REGION });

const rawSend = s3.send.bind(s3);

// Gate on environment — never add latency to real AWS calls in production
export const send = process.env.NODE_ENV !== 'production'
  ? withLatency(rawSend, 's3')
  : rawSend;
S3 with LocalStack

If you run tests against LocalStack, add slowdep to simulate the real S3 latency that LocalStack skips. Point endpoint to LocalStack, then wrap send with slowdep for realistic timing.

What to test

Scenarios this integration helps you verify

  • Upload progress UX — at p50 of 30ms for small files, do you even show a progress bar? At p99 of 500ms, users expect feedback.
  • Upload timeout and retry — large file uploads should have per-part retry, not whole-file retry. Verify your multipart upload logic handles errors.
  • Presigned URL expiry — if URL generation is slow and the client waits, does the URL still have enough time to live?
  • SlowDown (503) handling — S3 returns 503 SlowDown under high request rates; verify your code backs off and retries correctly.
  • Cross-region latency — cross-region S3 requests are significantly slower; use a custom profile with higher p50/p99 to model this.
  • Concurrent upload limits — if you upload many files concurrently, S3 SlowDown errors increase; test your concurrency limits.
  • Download during rendering — if your server fetches from S3 before responding to a user, that 30ms p50 adds directly to TTFB.
TypeScript

Typed S3 send wrapper

import { S3Client, PutObjectCommand, GetObjectCommandOutput } from '@aws-sdk/client-s3';
import type { ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-s3';
import type { Command } from '@smithy/types';
import { withLatency } from 'slowdep';

const s3 = new S3Client({ region: process.env.AWS_REGION });

// send is generic — return type matches the command type
const slowSend = withLatency(s3.send.bind(s3), 's3');

// TypeScript infers the correct output type per command
const putResult = await slowSend(new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: 'file.txt',
  Body: 'hello'
}));
// putResult.ETag is typed as string | undefined
console.log(putResult.ETag);