Implementing Sleep Functionality in TypeScript
You're hitting a third-party API in a loop, and halfway through your requests start bouncing back with 429 errors. The API has a rate limit, and you're blasting through it. You need a way to pace your requests, and what you actually need is a sleep function.
TypeScript doesn't have one built in. But because JavaScript is asynchronous, you can build a clean, non-blocking sleep using Promise<T> and setTimeout. In this guide, we'll cover everything from the basic implementation to practical patterns like retrying with exponential backoff, following backend best practices.
Creating a Sleep Function in TypeScript
The standard approach is a one-liner that wraps setTimeout in a Promise:
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Example usage:
sleep(2000).then(() => console.log("Resumed after 2 seconds"));
This works because setTimeout fires after the delay, which resolves the Promise, which unblocks any await that was waiting on it. The rest of your application continues running during the wait, so nothing freezes.
Using Async/Await for Delay
Pairing the sleep function with async/await makes the timing intent obvious at a glance:
async function notifyAfterDelay(message: string, ms: number): Promise<void> {
await sleep(ms);
console.log(message);
}
// Example usage:
notifyAfterDelay("Job complete", 1000);
The await makes the timing intent obvious at a glance, which is why most developers prefer it over .then() chains for anything involving delays. For more on async patterns in TypeScript, see end-to-end TypeScript with Convex.
Typed Delay: Returning a Value After a Wait
Sometimes you don't just want to pause, you want to resolve with a value after the delay. A generic delay<T> function handles that cleanly:
function delay<T>(ms: number, result?: T): Promise<T | undefined> {
return new Promise((resolve) => setTimeout(() => resolve(result), ms));
}
// Resolve with a default value after a delay
const config = await delay(500, { theme: "dark", lang: "en" });
console.log(config); // { theme: 'dark', lang: 'en' } - after 500ms
Two good use cases: enforcing a minimum loading spinner duration while also returning the loaded data, and mocking API latency in tests with a typed return value.
Pausing Execution with setTimeout
If you're working with older code or integrating with a callback-based API, you may not want Promises at all. You can schedule work with setTimeout directly:
function pauseExecution(callback: () => void, ms: number): void {
setTimeout(() => {
console.log("Execution resumed");
callback();
}, ms);
}
// Example usage
pauseExecution(() => {
console.log("Callback executed after delay");
}, 500);
This approach uses function callbacks rather than Promises. The key difference is that this won't work with await and doesn't pause the current execution context. It schedules the callback to run later while the rest of your code continues. If you're working with modern TypeScript applications, the Promise-based approach is generally the better choice.
Creating a Cancellable TypeScript Sleep
Standard Promise-based sleep functions can't be easily cancelled. Here's a version that supports cancellation through a returned cancel function:
function cancellableSleep(ms: number): {
promise: Promise<void>;
cancel: () => void
} {
let timeoutId: NodeJS.Timeout;
let resolveFunction: () => void;
const promise = new Promise<void>((resolve) => {
resolveFunction = resolve;
timeoutId = setTimeout(resolve, ms);
});
const cancel = () => {
clearTimeout(timeoutId);
resolveFunction();
};
return { promise, cancel };
}
// Example usage
async function testCancellableSleep() {
console.log("Starting a cancellable sleep");
const { promise, cancel } = cancellableSleep(5000);
// Cancel after 2 seconds
setTimeout(() => {
console.log("Cancelling sleep early");
cancel();
}, 2000);
await promise;
console.log("Sleep finished (either naturally or by cancellation)");
}
testCancellableSleep();
Returning { promise, cancel } lets the caller decide when to stop waiting. It's handy for interactive UI components that need to drop pending work when the user changes their mind.
Using AbortSignal for Cancellation
If you're building something that integrates with the browser's fetch API or other web platform APIs, accepting an AbortSignal is the more idiomatic pattern. It's the same interface used by fetch, addEventListener, and other async web APIs:
function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
// If already aborted, reject immediately
if (signal?.aborted) {
return reject(new DOMException("Sleep aborted", "AbortError"));
}
const timeoutId = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new DOMException("Sleep aborted", "AbortError"));
}, { once: true });
});
}
// Example: abort a sleep when the user navigates away
const controller = new AbortController();
try {
await sleepWithSignal(5000, controller.signal);
console.log("Sleep completed");
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
console.log("Sleep was cancelled");
}
}
// Cancel from elsewhere in your code:
controller.abort();
The AbortSignal approach composes well with AbortSignal.timeout() (which auto-aborts after a duration) and AbortSignal.any() (which combines multiple signals).
Managing API Requests with Sleep
Here's how to add a deliberate delay between each API call:
async function fetchApiWithDelay(url: string, ms: number): Promise<any> {
try {
const response = await fetch(url);
// Check if response is ok
if (!response.ok) {
throw new Error(`API responded with status: ${response.status}`);
}
// Add deliberate delay after successful request
await sleep(ms);
return await response.json();
} catch (error) {
console.error(`Error fetching from ${url}:`, error);
throw error;
}
}
// Example usage with multiple sequential requests
async function fetchMultipleApis() {
const apis = [
"https://api.example.com/users",
"https://api.example.com/products",
"https://api.example.com/orders"
];
for (const api of apis) {
try {
const data = await fetchApiWithDelay(api, 500); // 500ms between requests
console.log(`Data from ${api}:`, data);
} catch (error) {
// Continue with next API even if one fails
continue;
}
}
}
Pacing requests this way keeps you under rate limits without sacrificing control over the flow. It's a go-to pattern in backend services that talk to third-party APIs.
Retry with Exponential Backoff
A flat delay between retries is fine for rate limiting, but when you're recovering from failures, you want exponential backoff: wait longer after each attempt to avoid hammering a struggling service. Here's a typed implementation that uses sleep under the hood:
interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxAttempts = 4,
baseDelayMs = 200,
maxDelayMs = 10000,
} = options;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
// Exponential backoff: 200ms, 400ms, 800ms...
const backoff = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
console.warn(`Attempt ${attempt + 1} failed. Retrying in ${backoff}ms...`);
await sleep(backoff);
}
}
}
throw lastError;
}
// Example: retry a flaky API call
const userData = await retryWithBackoff(
() => fetch("https://api.example.com/users/42").then(r => r.json()),
{ maxAttempts: 4, baseDelayMs: 300 }
);
One improvement worth adding is jitter: a small random offset on top of the calculated delay. Without it, clients that all fail at the same moment will all retry at the same moment, flooding the server you were trying to go easy on:
const jitter = Math.random() * 100; // up to 100ms of randomness
const backoff = Math.min(baseDelayMs * Math.pow(2, attempt) + jitter, maxDelayMs);
Retry with backoff shows up constantly in serverless functions and backend services that call external APIs. It's one of the places sleep earns its keep.
Implementing Polling with Sleep
Sometimes you need to repeatedly check a condition until it becomes true, a pattern known as polling. Sleep functions are perfect for this:
async function waitForCondition(
checkCondition: () => boolean | Promise<boolean>,
maxAttempts: number = 10,
intervalMs: number = 1000
): Promise<boolean> {
let attempts = 0;
while (attempts < maxAttempts) {
// Allow for async condition checks
const result = await Promise.resolve(checkCondition());
if (result) {
return true;
}
attempts++;
console.log(`Condition not met. Attempt ${attempts}/${maxAttempts}`);
// Only sleep if we're going to check again
if (attempts < maxAttempts) {
await sleep(intervalMs);
}
}
return false; // Condition never met within max attempts
}
// Example usage: Wait for an element to appear in the DOM
async function waitForElement(selector: string): Promise<HTMLElement | null> {
const found = await waitForCondition(
() => document.querySelector(selector) !== null,
20, // Check up to 20 times
500 // Every 500ms
);
return found ? document.querySelector(selector) : null;
}
Polling works well any time you're waiting on something you can't subscribe to directly, like a background job finishing or a resource becoming available. See Convex's validation docs for patterns around checking async state server-side.
Handling Asynchronous Sleep
Sometimes you need sleep to work alongside Promise.race or Promise.all. Here are two utilities that do that:
// Sleep function for parallel operations
async function sleepRace(timeouts: number[]): Promise<number> {
// Create an array of sleep promises with their index
const promises = timeouts.map(async (timeout, index) => {
await sleep(timeout);
return index;
});
// Return the index of the first resolved promise
return Promise.race(promises);
}
// Sleep within Promise.all pattern
async function sleepAll(operations: Array<Promise<any>>, minTime: number): Promise<any[]> {
// This ensures the combined operation takes at least minTime milliseconds
const results = await Promise.all([
Promise.all(operations),
sleep(minTime)
]);
// Return just the operation results (first item in the array)
return results[0];
}
// Example usage
async function runParallelSleepExamples() {
// Which timeout completes first?
const winner = await sleepRace([1000, 500, 2000]);
console.log(`The ${winner} timeout finished first`);
// Ensure loading state shows for at least 800ms for better UX
const data = await sleepAll([
fetch('https://api.example.com/data').then(r => r.json()),
fetch('https://api.example.com/config').then(r => r.json())
], 800);
console.log('All operations complete with minimum duration', data);
}
sleepRace is useful for timeouts. sleepAll is useful when you want to guarantee a minimum duration, like keeping a loading spinner visible long enough that it doesn't flash.
Where Things Go Wrong
Blocking the Main Thread
Problem: Incorrectly implementing sleep can freeze the UI in browser environments.
Solution: Always use Promise-based solutions rather than synchronous loops:
// DON'T do this - will freeze the browser
function badSleep(ms: number): void {
const start = Date.now();
while (Date.now() - start < ms) {
// This busy loop blocks the main thread
}
}
// DO this instead
function goodSleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Memory Leaks with Unhandled Timeouts
Problem: Forgetting to clean up timeouts when components unmount.
Solution: Always store timeout IDs and clear them when needed:
// React component example
function useTimeout(callback: () => void, ms: number) {
useEffect(() => {
const timeoutId = setTimeout(callback, ms);
// Clean up the timeout when the component unmounts
return () => clearTimeout(timeoutId);
}, [callback, ms]);
}
Unexpected Behavior with Multiple Awaits
Problem: Awaiting multiple sleep calls in sequence without understanding how they stack up.
Solution: Sequential await calls add up their durations. If you want them to run concurrently, use Promise.all:
async function sequentialVsParallelExample() {
console.log("Start");
// These run sequentially - total wait: 2 seconds
await sleep(1000);
console.log("After 1 second");
await sleep(1000);
console.log("After 2 seconds total");
// For parallel sleep, do this - total wait: 2 seconds (not 3)
await Promise.all([sleep(1000), sleep(2000)]);
console.log("After 2 seconds (the longer of the two parallel sleeps)");
}
For more on error handling in async functions, check out Convex's testing documentation.
Practical Uses for Sleep in TypeScript
Rate Limiting API Requests
When you need to respect API rate limits, sleep functions can help you pace requests:
async function fetchWithRateLimit<T>(urls: string[], requestsPerSecond = 2): Promise<T[]> {
const results: T[] = [];
const delayBetweenRequests = 1000 / requestsPerSecond;
for (const url of urls) {
const response = await fetch(url);
const data = await response.json();
results.push(data);
// Add delay before next request
await sleep(delayBetweenRequests);
}
return results;
}
Debouncing User Input
When building interactive interfaces, sleep helps implement debounce patterns:
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function(...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
// Example: Debounced search function
const debouncedSearch = debounce((query: string) => {
console.log(`Searching for: ${query}`);
// Actual search logic here
}, 300);
// Usage
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
Implementing Progressive Loading
Create a better user experience by staggering UI updates:
async function progressivelyRenderItems<T>(
items: T[],
renderFn: (item: T) => void,
batchSize = 5,
delayMs = 50
): Promise<void> {
// Process items in batches
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
// Render the current batch
batch.forEach(renderFn);
// If not the last batch, add a small delay for better UX
if (i + batchSize < items.length) {
await sleep(delayMs);
}
}
}
All three patterns show up in real production code, frontend and backend alike. Convex's Node.js client uses similar approaches for managing async data flow.
TypeScript Sleep: Key Takeaways
A few rules worth keeping in mind as you work with sleep and delays:
- Always use
Promise-based sleep. Synchronous busy-loops block the main thread and freeze the UI. - Use
async/awaitfor readability. It makes timing logic easy to follow at a glance, especially in complex flows. - Use
AbortSignalfor cancellable sleeps when your code needs to integrate with fetch or other web platform APIs. - Add exponential backoff with jitter when retrying failed operations. Flat delays cause thundering herd problems under load.
- Clean up timeouts in React components and other lifecycle-managed contexts to avoid memory leaks.
- Use
Promise.allwhen you want parallel waits. Sequentialawait sleep()calls stack up their durations.
For more async patterns in production TypeScript, Convex's TypeScript ecosystem is worth exploring.