Simulating Realistic Server Behavior in Mirage JS

Mirage JS already lets you create records, intercept API requests, and shape returned data. But a real backend isn't just about the data — it has latency, it can return different HTTP status codes, and it may not handle every request your frontend makes.

This article covers three mechanisms that bring your mock server closer to production reality: timing, passthrough, and the Response class.

Controlling Response Delay with timing

Network requests are asynchronous. You don't control when a response arrives, so your UI needs to handle delay gracefully — showing loaders, for instance. To help you test these states, Mirage lets you simulate slow networks using the timing option, which specifies a delay in milliseconds that Mirage waits before responding to a route handler.

By default, Mirage applies a 400ms delay in development and 0 in tests so your test suite stays fast.

Setting a Global Delay

You can change the default delay for all routes by setting timing in the routes() method of your server instance.

import { Server } from 'miragejs'

new Server({
    routes() {
        this.routes = 1000
    }
})

With this configuration, any route handler — like the one below — will wait 1000 milliseconds before sending a response.

this.get('/users', (schema) => {
    return schema.users.all();
});

To keep route handlers concise, you can also use ES6 destructuring instead of directly accessing the schema object.

this.get('/users', ({ users }) => {
    return users.all()
}

Per-Route Delay

Setting a global delay is useful, but you may not want every endpoint to respond slowly. Mirage lets you pass the timing option in the config object that appears at the end of an individual route handler.

this.get('/users', ({ users }) => {
  return users.all();
 }, { timing: 1000 });

You can mix both approaches: define a global this.timing and then override it on a specific route.

this.timing = 1000

this.get('users', ( { users } ) => {
    return users.all()
})

this.get('/users/:id', ({ users }, request) => {
    let { id } = request.params;
     return users.find(id);
 }, { timing: 500 });

In the example above, a request to /users/1 returns in half the time of other routes, since the route-level timing of 500 overrides the global 1000.

{
  "user": {
    "name": "Kelvin Omereshone",
    "age": 23,
    "id": "1"
  }
}

Letting Unhandled Requests Through with passthrough

By default, Mirage throws an error when your app requests an endpoint that has no route handler defined:

"Error: Mirage: Your app tried to GET '/unknown', but there was no route defined to handle this request. Define a route for this endpoint in your routes() config. Did you forget to define a namespace?"

If you have an actual backend and only want to mock endpoints that don't exist yet, you can tell Mirage to let unmocked requests pass through to the network. Call the passthrough method inside routes().

import { Server } from 'miragejs'

new Server({
    routes() {
        // you can define your route handlers above the passthrough call
        this.passthrough()
    }
})

It's best to place passthrough at the bottom of routes() so your explicit handlers always take precedence.

Whitelisting Specific Routes

You don't have to whitelist everything. Pass route strings to passthrough to only allow those endpoints through:

this.passthrough('/reviews', '/pets)

You can also make multiple calls to passthrough, but passing an array of strings in one call keeps your config cleaner.

this.passthrough('/reviews')
this.passthrough('/pets')

Restricting by HTTP Verb

A whitelisted route allows all HTTP verbs through by default. To be more selective, provide an options array containing only the verbs you want to allow:

// this allows post requests to the /reviews route to passthrough
this.passthrough('/reviews', ['post']);

Multiple routes and a verbs array can be combined in a single call:

// this allows post and patch requests to /reviews and /pets routes to passthrough

this.passthrough('/pets', 'reviews', ['post', 'patch'])

Customizing HTTP Responses with the Response Class

Mirage defaults to a 200 status code, which indicates success. But a realistic mock needs to simulate errors and other states. The Response class gives you full control over the HTTP status, headers, and JSON payload returned from a route handler.

Its constructor accepts:

  • The HTTP status code
  • An object of HTTP headers
  • The data (JSON payload) sent to the frontend

To see how it works, start with a simple route handler that returns data directly.

this.get('users', ( { users } ) => {
return users.all()
})

First, import the Response class from Mirage.

import { Response } from 'miragejs'

Then rewrite the handler to return an instance of Response.

this.get('/users', ({ users }) => {
    return new Response(200, {}, users.all());
});

Note that an empty {} object is passed for the headers argument when no custom headers are needed.

This implementation mirrors the behavior of the original handler — both return the same payload — which suggests Mirage itself wraps handler responses in Response under the hood.

Simulating Server States

The first argument of the Response constructor is the status code, letting you simulate different scenarios: 400 for a bad request, 201 for resource creation, and so on. For instance, you can return a 404 when a user isn't found.

this.get('/users/:id', (schema, request) => {
   let { id } = request.params;
   return new Response(404, {}, { error: 'User with id ${id} not found'});
});

The resulting JSON payload delivered to your frontend reflects that error status and message.

{
  "error": "User with id 5 not found"
}

Setting Custom Headers

Pass an object as the second constructor argument to define response headers. For example, you can attach metadata to the /users/:id response.

this.get('/users/:id', (schema, request) => {
     let { id } = request.params;
     return new Response(404, {"Content-Type" : "application/json", author: 'Kelvin Omereshone' }, { error: `User with id ${id} not found`});
});

After this, the headers you set will appear in the Mirage logs in your browser console, allowing you to verify the response shape during development.

Bringing It Together

These three features let you simulate realistic backend conditions — delayed responses with timing, selective request forwarding with passthrough, and varied status codes with headers using Response — giving you more control over how your frontend behaves under different server scenarios. The next part of this series covers UI testing with Cypress.