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.
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.
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` }));
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');
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');
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); });
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;
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.
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.
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);